request list + structure

This commit is contained in:
hamid zarghami
2025-10-20 12:28:48 +03:30
commit dbe37e371e
89 changed files with 9547 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_TOKEN_NAME = 'negareh_t'
VITE_REFRESH_TOKEN_NAME = 'negareh_rt'
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/helpers/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+75
View File
@@ -0,0 +1,75 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) 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
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
## 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 defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// 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,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
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 defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/helpers/utils",
"ui": "@/components/ui",
"lib": "@/helpers",
"hooks": "@/hooks"
}
}
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>negareh-console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5127
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "negareh-console",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@radix-ui/react-checkbox": "^1.3.3",
"@tailwindcss/vite": "^4.1.14",
"@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-dropzone": "^14.3.8",
"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",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14",
"vite-tsconfig-paths": "^5.1.4",
"zustand": "^5.0.8"
},
"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",
"@vitejs/plugin-react": "^5.0.4",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+22
View File
@@ -0,0 +1,22 @@
import { type FC } from 'react'
import '@/assets/fonts/irancell/style.css'
import 'swiper/swiper-bundle.css';
import 'react-loading-skeleton/dist/skeleton.css'
import { BrowserRouter } from 'react-router-dom'
// import { getToken } from './config/func'
import MainRouter from './router/MainRouter'
// import AuthRouter from './router/AuthRouter'
const App: FC = () => {
// const isLoggedIn = getToken()
return (
<BrowserRouter>
{/* {isLoggedIn ? <MainRouter /> : <AuthRouter />} */}
<MainRouter />
</BrowserRouter>
)
}
export default App
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
@font-face {
font-family: "irancell";
font-style: thin;
font-weight: 200;
src: url("./irancell-extralight.ttf");
}
@font-face {
font-family: "irancell";
font-style: normal;
font-weight: 300;
src: url("./irancell-light.ttf");
}
@font-face {
font-family: "irancell";
font-style: thin;
font-weight: 600;
src: url("./irancell-bold.ttf");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 15L15 5" stroke="#8C90A3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 15L5 5" stroke="#8C90A3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

+17
View File
@@ -0,0 +1,17 @@
<svg width="121" height="65" viewBox="0 0 121 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M108.388 64.8275L105.829 60.6914L103.305 64.7757L100.779 60.6957L98.2247 64.8275H108.388Z" fill="#F6AF31"/>
<path d="M103.29 0L85.5955 28.6356L87.9213 32.4009C88.3373 31.7263 88.7425 31.0862 89.109 30.4784L87.9708 28.6356L103.29 3.84294L118.609 28.6356L117.486 30.4547C117.864 31.1228 118.297 31.7091 118.674 32.3772L120.985 28.6356L103.29 0Z" fill="#F6AF31"/>
<path d="M103.305 7.50488L85.6105 36.1405L103.305 64.7761L121 36.1405L103.305 7.50488ZM104.027 29.3124V12.516L109.375 21.1718L104.027 29.3124ZM110.255 22.5943L114.245 29.0516L104.027 43.5289V32.0734L110.255 22.5943ZM102.512 31.86V43.4362L92.5405 28.7693L96.7287 21.9908L102.512 31.86ZM97.6319 20.5316L102.514 12.6324V28.8641L97.6319 20.5316ZM104.027 46.1563L115.111 30.4526L118.625 36.1405L104.027 59.765V46.1563ZM91.6697 30.181L102.514 46.1304V59.6507L87.9881 36.1405L91.6697 30.181Z" fill="#4F5260"/>
<path d="M0.270386 54.3373V36.0225H2.79296L6.75338 47.0565H6.80428V36.0225H9.42865V54.3373H6.96015L2.94565 43.3287H2.89476V54.3373H0.270386Z" fill="#4F5260"/>
<path d="M13.5417 54.3373V36.0225H21.364V38.4907H16.1661V43.8662H20.6928V46.3345H16.1661V51.7099H21.364V54.3341H13.5417V54.3373Z" fill="#4F5260"/>
<path d="M24.6532 40.3418C24.6532 39.6038 24.7804 38.9518 25.0381 38.3856C25.2958 37.8194 25.6393 37.3487 26.0688 36.9702C26.4791 36.6108 26.9467 36.3372 27.4716 36.1464C27.9933 35.9587 28.5214 35.8633 29.0526 35.8633C29.5838 35.8633 30.1119 35.9587 30.6336 36.1464C31.1553 36.334 31.6324 36.6108 32.0619 36.9702C32.4722 37.3487 32.8094 37.8194 33.0671 38.3856C33.3248 38.9518 33.452 39.6038 33.452 40.3418V41.2674H30.8276V40.3418C30.8276 39.7088 30.6527 39.2412 30.2996 38.9391C29.9497 38.6401 29.5329 38.4906 29.0526 38.4906C28.5723 38.4906 28.1555 38.6401 27.8056 38.9391C27.4525 39.2412 27.2776 39.7056 27.2776 40.3418V50.0145C27.2776 50.6474 27.4525 51.115 27.8056 51.4172C28.1555 51.7194 28.5723 51.8688 29.0526 51.8688C29.5329 51.8688 29.9497 51.7194 30.2996 51.4172C30.6495 51.1182 30.8276 50.6506 30.8276 50.0145V46.5665H28.744V44.2509H33.452V50.0113C33.452 50.7842 33.3248 51.4426 33.0671 51.9929C32.8094 52.54 32.4754 52.9948 32.0619 53.3574C31.6324 53.7359 31.1585 54.0159 30.6336 54.2067C30.1087 54.3944 29.5838 54.4898 29.0526 54.4898C28.5214 54.4898 27.9933 54.3944 27.4716 54.2067C26.9467 54.019 26.4823 53.7359 26.0688 53.3574C25.6393 52.998 25.2958 52.5432 25.0381 51.9929C24.7804 51.4458 24.6532 50.7842 24.6532 50.0113V40.3418Z" fill="#4F5260"/>
<path d="M36.3277 54.3373L40.4185 36.0225H42.6039L46.6948 54.3373H44.0704L43.2974 50.4027H39.7219L38.9489 54.3373H36.3277ZM42.8107 47.9312L41.5255 41.293H41.4746L40.1895 47.9312H42.8107Z" fill="#4F5260"/>
<path d="M49.7263 54.3373V36.0225H53.9444C57.03 36.0225 58.576 37.8132 58.576 41.3979C58.576 42.4794 58.4074 43.3955 58.0734 44.1493C57.7394 44.9031 57.1509 45.5138 56.3111 45.9751L59.1423 54.3341H56.362L53.919 46.5158H52.3507V54.3341H49.7263V54.3373ZM52.3507 38.4907V44.2002H53.8426C54.307 44.2002 54.6729 44.1366 54.9496 44.0093C55.2232 43.8821 55.4395 43.6976 55.5922 43.4559C55.729 43.2141 55.8244 42.9183 55.8753 42.5685C55.9262 42.2186 55.9517 41.8114 55.9517 41.347C55.9517 40.8827 55.9262 40.4755 55.8753 40.1256C55.8244 39.7758 55.7194 39.4704 55.5667 39.2128C55.2423 38.7325 54.622 38.4907 53.7154 38.4907H52.3507Z" fill="#4F5260"/>
<path d="M62.4315 54.3373V36.0225H70.2537V38.4907H65.0558V43.8662H69.5857V46.3345H65.059V51.7099H70.2569V54.3341H62.4315V54.3373Z" fill="#4F5260"/>
<path d="M73.6956 54.3373V36.0225H76.32V43.8662H79.5615V36.0225H82.1859V54.3373H79.5615V46.1818H76.32V54.3373H73.6956Z" fill="#4F5260"/>
<path d="M0.429445 64.5349C0.155874 64.2327 0.0127242 63.7938 0 63.2149H0.642575C0.661662 63.6379 0.753912 63.9401 0.925689 64.1214C1.09747 64.3059 1.34241 64.3981 1.66051 64.3981C1.98498 64.3981 2.22674 64.3059 2.38579 64.1214C2.54485 63.9369 2.62437 63.657 2.62437 63.2785C2.62437 62.9477 2.55439 62.6964 2.41124 62.5278C2.27128 62.3593 2.01679 62.2002 1.64779 62.0475L1.28515 61.9044C0.874789 61.7422 0.569409 61.5354 0.362641 61.281C0.159053 61.0265 0.0572567 60.6862 0.0572567 60.26C0.0572567 59.7542 0.200406 59.3503 0.486702 59.0513C0.772997 58.7491 1.16427 58.5996 1.66369 58.5996C2.14085 58.5996 2.51622 58.7396 2.79297 59.0195C3.06972 59.2994 3.21923 59.7192 3.24468 60.279H2.62119C2.57984 59.5506 2.25537 59.1849 1.65415 59.1849C1.34241 59.1849 1.10701 59.2739 0.947954 59.4552C0.792082 59.6365 0.712559 59.891 0.712559 60.2218C0.712559 60.5176 0.782542 60.7498 0.925689 60.9184C1.06566 61.087 1.3106 61.2364 1.65415 61.37L2.02315 61.5132C2.43987 61.6754 2.75162 61.8885 2.96157 62.1557C3.17152 62.4197 3.27649 62.7918 3.27649 63.2658C3.27649 63.8129 3.13334 64.2359 2.85341 64.5349C2.5703 64.8339 2.15994 64.9834 1.62552 64.9834C1.10383 64.9866 0.703016 64.8339 0.429445 64.5349Z" fill="#4F5260"/>
<path d="M21.6312 63.3194H19.8053L19.4076 64.9098H18.7205L20.4192 58.6787H21.0236L22.7223 64.9098H22.0256L21.6312 63.3194ZM21.488 62.7501L20.7214 59.6711L19.9548 62.7501H21.488Z" fill="#4F5260"/>
<path d="M40.5903 62.3146V64.9069H39.9414V62.3146L38.5767 58.6758H39.2988L40.2659 61.5289L41.2329 58.6758H41.955L40.5903 62.3146Z" fill="#4F5260"/>
<path d="M61.4867 63.3194H59.6608L59.2632 64.9098H58.576L60.2747 58.6787H60.8791L62.5778 64.9098H61.8812L61.4867 63.3194ZM61.3436 62.7501L60.5769 59.6711L59.8103 62.7501H61.3436Z" fill="#4F5260"/>
<path d="M82.4594 58.6787V64.9098H81.801L79.6697 60.1228V64.9098H79.0112V58.6787H79.6697L81.801 63.4721V58.6787H82.4594Z" fill="#4F5260"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+41
View File
@@ -0,0 +1,41 @@
import type { ButtonHTMLAttributes, FC, ReactNode } from 'react'
import { memo } from 'react'
import MoonLoader from "react-spinners/MoonLoader"
import { type XOR } from '@/helpers/types'
import { clx } from '@/helpers/utils'
type Props = {
className?: string;
isLoading?: boolean,
percentage?: number,
} & ButtonHTMLAttributes<HTMLButtonElement> &
XOR<{ children: ReactNode }, { label: string }>;
const Button: FC<Props> = memo((props: Props) => {
const buttonClass = clx(
'flex rounded-xl items-center justify-center text-center h-10 text-sm bg-primary w-full',
props.disabled && 'cursor-not-allowed opacity-60',
props.className
);
return (
<button {...props} className={`${buttonClass} ${props.className}`} disabled={props.disabled || props.isLoading}>
{
props.isLoading ?
<div className='flex gap-2 items-center'>
{
!props.percentage ?
<MoonLoader size={20} color='#fff' />
:
<span>{props.percentage}%</span>
}
</div>
:
props.label || props.children
}
</button>
)
})
export default Button
+85
View File
@@ -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> = (props) => {
const [value, setValue] = useState<DateObject | null>(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 (
<div className="w-full">
{
props.label &&
<div className='text-sm text-foreground font-medium mb-2'>{props.label}</div>
}
<div className={clx(
'relative',
props.readOnly && 'readOny',
props.label && 'mt-1'
)}>
<DatePicker
placeholder={props.placeholder}
value={value}
onChange={(date) => setValue(date as DateObject)}
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-right"
className={props.className}
readOnly={props.readOnly}
/>
{props.error_text && props.error_text !== '' && (
<div className="text-xs text-right text-red-600 mt-2 mr-2 font-medium">
{props.error_text}
</div>
)}
<Calendar
size={20}
color='#8c90a3'
className='absolute top-0 bottom-0 my-auto left-2 pointer-events-none'
/>
</div>
</div>
);
};
export default DatePickerComponent;
+56
View File
@@ -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: Props) => {
useEffect(() => {
if (props.open) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'auto'
}
}, [props.open])
return (
<Fragment>
{
props.open && (
<Fragment>
<div style={{ maxWidth: props.width }} className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-y-auto fixed inset-0 z-[9999999] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto'>
<div className='relative xl:h-full max-h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2'>
<div className='h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full outline-none focus:outline-none bg-card border border-border'>
{
props.isHeader && props.title_header &&
<div onClick={props.close} className='pb-6 border-b border-border border-opacity-20'>
<div className={`h-[5px] w-[200px] mx-auto rounded-full mb-4 xl:hidden bg-[#D1D3D7]'
}`}></div>
<HeaderModal close={props.close} label={props.title_header} />
</div>
}
{props.children}
</div>
</div>
</div>
<div onClick={props.close} className='fixed size-full bg-black/65 top-0 bottom-0 right-0 inset-0 z-50 '></div>
</Fragment>
)
}
</Fragment>
)
}
export default DefaulModal
+30
View File
@@ -0,0 +1,30 @@
import { type FC, Fragment } from 'react'
import Td from './Td'
import Skeleton from 'react-loading-skeleton'
type Props = {
tdCount: number,
trCount?: number,
}
const DefaultTableSkeleton: FC<Props> = ({ tdCount, trCount = 5 }) => {
return (
<Fragment>
{
Array.from({ length: trCount }).map((_, rowIndex) => (
<tr className="w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5]" key={rowIndex}>
{
Array.from({ length: tdCount }).map((_, colIndex) => (
<Td text={''} key={colIndex}>
<Skeleton />
</Td>
))
}
</tr>
))
}
</Fragment>
)
}
export default DefaultTableSkeleton
+15
View File
@@ -0,0 +1,15 @@
import { type FC } from 'react'
type Props = {
errorText: string
}
const Error: FC<Props> = (props: Props) => {
return (
<div className='mt-1.5 font-normal text-red-500 text-[11px]'>
{props.errorText}
</div>
)
}
export default Error
+224
View File
@@ -0,0 +1,224 @@
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<string, string | null>;
interface FiltersProps {
fields: FieldType[];
onChange: (filters: FilterValues) => void;
initialValues?: FilterValues;
className?: string;
fieldClassName?: string;
searchField?: string;
}
const Filters: FC<FiltersProps> = ({
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<FilterValues>({});
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
if (Object.keys(initialValues).length > 0) {
setFilters(initialValues);
const inputFields: Record<string, string> = {};
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<string, string> = {};
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<string, number> = {};
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<HTMLInputElement>) => {
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 (
<DatePicker
key={field.name}
placeholder={field.placeholder}
onChange={(value) => handleChange(field.name, moment(value, 'jYYYY/jMM/jDD').format('YYYY-MM-DD'))}
defaultValue={currentValue || field.defaultValue || ''}
/>
);
case 'select':
return (
<Select
key={field.name}
placeholder={field.placeholder}
onChange={(e) => handleChange(field.name, e.target.value)}
defaultValue={currentValue || field.defaultValue || ''}
items={field.options}
/>
);
case 'input':
return (
<Input
key={field.name}
placeholder={field.placeholder}
variant="search"
className="w-full md:min-w-[230px] md:max-w-[230px]"
value={currentValue || field.defaultValue || ''}
onChange={(e) => handleInputChange(field.name, e)}
/>
);
}
};
const searchFieldObj = fields.find(field => field.name === searchField);
const otherFields = fields.filter(field => field.name !== searchField);
return (
<div className={className}>
<div className="flex md:hidden items-center justify-between w-full mb-2">
<button
onClick={() => setIsFiltersOpen(!isFiltersOpen)}
className='flex items-center gap-2 px-3 py-2 rounded-lg transition-colors bg-card text-card-foreground border border-border hover:bg-secondary hover:bg-opacity-80'
>
<Filter
size={18}
color='#000000'
/>
<span className="text-sm">فیلترها</span>
</button>
{searchFieldObj && (
<div className="flex-1 mr-4">
{renderField(searchFieldObj)}
</div>
)}
</div>
<div className="hidden md:flex md:flex-row justify-between items-center gap-4 w-full">
<div className={fieldClassName}>
{fields.map(renderField)}
</div>
</div>
<DefaulModal
open={isFiltersOpen}
close={() => setIsFiltersOpen(false)}
isHeader={true}
title_header="فیلتر کردن نتایج"
>
<div className="pb-5 gap-4 flex flex-col-reverse mt-3">
{otherFields.map(field => (
<div key={field.name} className="w-full">
<label className="block text-sm font-medium text-foreground mb-2">
{field.placeholder}
</label>
<div className="w-full">
{renderField(field)}
</div>
</div>
))}
</div>
<div className='mt-6'>
<Button
label='بستن'
onClick={() => setIsFiltersOpen(false)}
/>
</div>
</DefaulModal>
</div>
);
};
export default Filters;
+125
View File
@@ -0,0 +1,125 @@
import React from "react";
import { clx } from "@/helpers/utils";
type GridWrapperProps = {
children: React.ReactNode;
desktop: number; // تعداد ستون‌ها در دسکتاپ
mobile: number; // تعداد ستون‌ها در موبایل
gapDesktop?: number; // فاصله بین آیتم‌ها در دسکتاپ. اگر مقدار > 12 باشد به عنوان px تفسیر می‌شود (مثلاً 24 => 24px)
gapMobile?: number; // فاصله بین آیتم‌ها در موبایل. اگر مقدار > 12 باشد به عنوان px تفسیر می‌شود
className?: string;
};
const GRID_COLS_CLASS: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
9: "grid-cols-9",
10: "grid-cols-10",
11: "grid-cols-11",
12: "grid-cols-12",
};
const MD_GRID_COLS_CLASS: Record<number, string> = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
5: "md:grid-cols-5",
6: "md:grid-cols-6",
7: "md:grid-cols-7",
8: "md:grid-cols-8",
9: "md:grid-cols-9",
10: "md:grid-cols-10",
11: "md:grid-cols-11",
12: "md:grid-cols-12",
};
// نسخه‌ی دسکتاپ با پیشوند lg: به صورت literal تا با purge مشکلی نداشته باشد
const LG_GRID_COLS_CLASS: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
5: "lg:grid-cols-5",
6: "lg:grid-cols-6",
7: "lg:grid-cols-7",
8: "lg:grid-cols-8",
9: "lg:grid-cols-9",
10: "lg:grid-cols-10",
11: "lg:grid-cols-11",
12: "lg:grid-cols-12",
};
// نگاشت Gap بر اساس scale رایج Tailwind: gap-N که هر N معادل 4px است (N * 4px)
const GAP_CLASS: Record<number, string> = {
0: "gap-0", 1: "gap-1", 2: "gap-2", 3: "gap-3", 4: "gap-4", 5: "gap-5",
6: "gap-6", 7: "gap-7", 8: "gap-8", 9: "gap-9", 10: "gap-10", 11: "gap-11",
12: "gap-12", 13: "gap-13", 14: "gap-14", 15: "gap-15", 16: "gap-16", 17: "gap-17",
18: "gap-18", 19: "gap-19", 20: "gap-20", 21: "gap-21", 22: "gap-22", 23: "gap-23", 24: "gap-24",
};
const LG_GAP_CLASS: Record<number, string> = {
0: "lg:gap-0", 1: "lg:gap-1", 2: "lg:gap-2", 3: "lg:gap-3", 4: "lg:gap-4", 5: "lg:gap-5",
6: "lg:gap-6", 7: "lg:gap-7", 8: "lg:gap-8", 9: "lg:gap-9", 10: "lg:gap-10", 11: "lg:gap-11",
12: "lg:gap-12", 13: "lg:gap-13", 14: "lg:gap-14", 15: "lg:gap-15", 16: "lg:gap-16", 17: "lg:gap-17",
18: "lg:gap-18", 19: "lg:gap-19", 20: "lg:gap-20", 21: "lg:gap-21", 22: "lg:gap-22", 23: "lg:gap-23", 24: "lg:gap-24",
};
function clampToRange(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function getGridColsClass(count: number): string {
const safe = clampToRange(count, 1, 12);
return GRID_COLS_CLASS[safe];
}
function getLgGridColsClass(count: number): string {
const safe = clampToRange(count, 1, 12);
return LG_GRID_COLS_CLASS[safe];
}
function getMdGridColsClass(count: number): string {
const safe = clampToRange(count, 1, 12);
return MD_GRID_COLS_CLASS[safe];
}
function getGapClass(value?: number): string | undefined {
if (value === undefined) return undefined;
// اگر کاربر مقدار را به صورت px فرستاده باشد (بزرگ‌تر از 12)، آن را به scale تبدیل می‌کنیم: N = round(px/4)
const interpretedAsScale = value > 12 ? Math.round(value / 4) : value;
const safe = clampToRange(interpretedAsScale, 0, 24);
return GAP_CLASS[safe];
}
function getLgGapClass(value?: number): string | undefined {
if (value === undefined) return undefined;
const interpretedAsScale = value > 12 ? Math.round(value / 4) : value;
const safe = clampToRange(interpretedAsScale, 0, 24);
return LG_GAP_CLASS[safe];
}
export default function GridWrapper(props: GridWrapperProps) {
const { children, desktop, mobile, className, gapDesktop = 4, gapMobile = 4 } = props;
const mobileCols = getGridColsClass(mobile);
const desktopColsMd = getMdGridColsClass(desktop);
const desktopColsLg = getLgGridColsClass(desktop);
const mobileGap = getGapClass(gapMobile);
const desktopGap = getLgGapClass(gapDesktop);
return (
<div className={clx("grid", mobileCols, desktopColsMd, desktopColsLg, mobileGap, desktopGap, className)}>
{children}
</div>
);
}
+25
View File
@@ -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: Props) => {
return (
<div className='flex justify-between items-center'>
<div className='text-sm text-foreground font-medium'>{props.label}</div>
<div className='size-7 rounded-full flex justify-center items-center transition-colors bg-white bg-opacity-35 hover:bg-opacity-50'>
<CloseCircle
size={20}
color='#000000'
onClick={props.close}
className="cursor-pointer"
/>
</div>
</div>
)
}
export default HeaderModal
+117
View File
@@ -0,0 +1,117 @@
import { type FC, type InputHTMLAttributes, useEffect, useState } from 'react'
import { clx } from '../helpers/utils';
import { Eye, EyeSlash, SearchNormal } from 'iconsax-react';
import Error from './Error';
type Variant = "floating_outlined" | "primary" | "search";
type Props = {
label?: string;
className?: string;
variant?: Variant;
error_text?: string;
onEnter?: () => void;
unit?: string;
seprator?: boolean;
isNotRequired?: boolean;
onChangeSearchFinal?: (value: string) => void;
} & InputHTMLAttributes<HTMLInputElement>
const formatNumber = (value: string | number): string => {
if (!value) return '';
const inputValue = String(value).replace(/,/g, '');
return inputValue.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
const Input: FC<Props> = (props: Props) => {
const [formattedValue, setFormattedValue] = useState<string>(
props.value ? formatNumber(props.value as string) : ''
);
const [showPassword, setShowPassword] = useState<boolean>(false)
const [search, setSearch] = useState<string>('')
const inputClass = clx(
'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' && 'border-0 ps-10 mt-0',
props.className
);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (props.seprator) {
const inputValue = event.target.value.replace(/,/g, ''); // حذف کاماها
const formatted = formatNumber(inputValue);
// به‌روزرسانی مقدار قالب‌بندی‌شده
setFormattedValue(formatted);
// ارسال مقدار خام به `onChange` والد
props.onChange?.({
...event,
target: {
...event.target,
value: inputValue,
},
});
} else {
props.onChange?.(event);
}
};
useEffect(() => {
if (props.value) {
setFormattedValue(formatNumber(props.value as string));
} else {
setFormattedValue('');
}
}, [props.value])
useEffect(() => {
if (props.variant === 'search' && props.onChangeSearchFinal) {
const timeout = setTimeout(() => {
props.onChangeSearchFinal?.(search)
}, 1000)
return () => clearTimeout(timeout)
}
}, [search, props])
return (
<div className='w-full'>
<label className='text-sm'>
{props.label}
</label>
<div className={clx('w-full relative', props.label && 'mt-1')}>
<input {...props} onChange={(e) => {
setSearch(e.target.value)
handleInputChange(e)
}} value={props.seprator ? formattedValue : props.value} type={props.type === 'password' && showPassword ? 'text' : props.type === 'password' ? 'password' : undefined} className={inputClass} />
{
props.type === 'password' &&
(showPassword ?
<Eye onClick={() => setShowPassword((oldValue) => !oldValue)} className='w-5 absolute top-0 bottom-0 cursor-pointer my-auto left-3' /> :
<EyeSlash onClick={() => setShowPassword((oldValue) => !oldValue)} className='w-5 absolute top-0 bottom-0 cursor-pointer my-auto left-3' />
)
}
{
props.variant === 'search' &&
<SearchNormal size={20} color='#8C90A3' className='absolute top-0 w-5 bottom-0 my-auto right-3' />
}
{
props.error_text &&
<Error
errorText={props.error_text}
/>
}
</div>
</div>
)
}
export default Input
+109
View File
@@ -0,0 +1,109 @@
import React from "react";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
}) => {
// اگر فقط یک صفحه داریم، pagination نمایش داده نمی‌شود
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pageNumbers: (number | string)[] = [];
const maxVisiblePages = window.innerWidth < 768 ? 3 : 5; // تعداد کمتر برای موبایل
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// نمایش صفحه اول
pageNumbers.push(1);
if (currentPage > 3) {
pageNumbers.push("...");
}
// صفحات میانی
const start = Math.max(2, Math.min(currentPage - 1, totalPages - 3));
const end = Math.min(totalPages - 1, Math.max(currentPage + 1, 4));
for (let i = start; i <= end; i++) {
if (!pageNumbers.includes(i)) {
pageNumbers.push(i);
}
}
if (currentPage < totalPages - 2) {
pageNumbers.push("...");
}
// نمایش صفحه آخر
if (!pageNumbers.includes(totalPages)) {
pageNumbers.push(totalPages);
}
}
return pageNumbers;
};
const pageNumbers = getPageNumbers();
return (
<div className="flex justify-center items-center gap-1 md:gap-2 py-4 md:py-6 mt-3 md:mt-4 border-t border-gray-100">
{/* دکمه صفحه قبل */}
<button
className={`px-2 md:px-3 py-2 text-xs md:text-sm rounded-lg transition-colors ${currentPage === 1
? "text-gray-400 cursor-not-allowed"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`}
onClick={() => currentPage > 1 && onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
قبلی
</button>
{/* شماره صفحات */}
<div className="flex gap-1">
{pageNumbers.map((page, index) =>
typeof page === "number" ? (
<button
key={index}
className={`w-8 h-8 md:w-10 md:h-10 text-xs md:text-sm rounded-lg transition-colors ${currentPage === page
? "bg-primary text-white shadow-sm"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`}
onClick={() => onPageChange(page)}
>
{page}
</button>
) : (
<span key={index} className="flex items-center px-1 md:px-2 text-gray-400 text-xs md:text-sm">
{page}
</span>
)
)}
</div>
{/* دکمه صفحه بعد */}
<button
className={`px-2 md:px-3 py-2 text-xs md:text-sm rounded-lg transition-colors ${currentPage === totalPages
? "text-gray-400 cursor-not-allowed"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`}
onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
بعدی
</button>
</div>
);
};
export default Pagination;
+145
View File
@@ -0,0 +1,145 @@
import React, { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { More } from 'iconsax-react'
export interface RowActionItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
className?: string;
}
interface RowActionsDropdownProps {
actions: RowActionItem[];
className?: string;
trigger?: React.ReactNode;
topOffset?: number;
leftOffset?: number;
topOffsetMobile?: number;
leftOffsetMobile?: number;
}
const RowActionsDropdown: React.FC<RowActionsDropdownProps> = ({
actions,
className = '',
trigger,
topOffset = 0,
leftOffset = 0,
topOffsetMobile = 0,
leftOffsetMobile = 0
}) => {
const [isOpen, setIsOpen] = useState(false)
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 })
const dropdownRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const calculatePosition = (rect: DOMRect) => {
const isMobile = window.innerWidth < 768
const dropdownWidth = isMobile ? 140 : 150
const dropdownHeight = actions.length * (isMobile ? 36 : 40)
// انتخاب offset بر اساس سایز صفحه
const currentTopOffset = isMobile ? topOffsetMobile : topOffset
const currentLeftOffset = isMobile ? leftOffsetMobile : leftOffset
let left = rect.left + window.scrollX - dropdownWidth + rect.width + currentLeftOffset
let top = rect.bottom + window.scrollY + currentTopOffset
if (left + dropdownWidth > window.innerWidth) {
left = rect.left + window.scrollX - dropdownWidth + currentLeftOffset
}
if (left < 0) {
left = isMobile ? 20 : 40
}
if (top + dropdownHeight > window.innerHeight + window.scrollY) {
top = rect.top + window.scrollY - dropdownHeight + currentTopOffset
}
// اطمینان از اینکه dropdown از صفحه خارج نشود
if (left < 0) left = 20
if (left + dropdownWidth > window.innerWidth) left = window.innerWidth - dropdownWidth - 20
if (top < 0) top = 20
if (top + dropdownHeight > window.innerHeight) top = window.innerHeight - dropdownHeight - 20
return { top, left }
}
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation()
if (!isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
const calculatedPosition = calculatePosition(rect)
setDropdownPosition(calculatedPosition)
}
setIsOpen(!isOpen)
}
const handleActionClick = (action: RowActionItem) => {
action.onClick()
setIsOpen(false)
}
const renderDropdown = () => {
if (!isOpen) return null
return createPortal(
<div
ref={dropdownRef}
className="fixed py-3 md:py-3 bg-white dark:bg-[#252526] rounded-xl md:rounded-2xl shadow-lg z-[5] min-w-[140px] md:min-w-[150px] border border-[#d0d0d0]"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
}}
>
{actions.map((action, index) => (
<button
key={index}
onClick={() => handleActionClick(action)}
className={`w-full mt-1 text-right px-2 md:px-3 py-1 md:py-1.5 text-xs md:text-[13px] flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-[#2d2d30] ${index === 0 ? 'rounded-t-lg' : ''} ${action.className || ''}`}
>
{action.icon && <span className="ml-2">{action.icon}</span>}
<span className={`${action.label === 'حذف' ? 'text-red-500' : ''} dark:text-gray-200`}>{action.label}</span>
</button>
))}
</div>,
document.body
)
}
return (
<div className={className}>
<button
ref={buttonRef}
onClick={handleToggle}
className="mt-2 hover:bg-gray-100 dark:hover:bg-[#2d2d30] rounded-full transition-colors"
aria-haspopup="menu"
aria-expanded={isOpen}
>
{trigger ? trigger : (
<More size={16} color={'black'} className="rotate-90" />
)}
</button>
{renderDropdown()}
</div>
)
}
export default RowActionsDropdown
+64
View File
@@ -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<HTMLSelectElement>
const Select: FC<Props> = (props: Props) => {
return (
<div className='w-full'>
{
props.label &&
<label className='text-sm text-primary-content'>
{props.label}
</label>
}
<div className='relative'>
<select {...props} className={clx(
'w-full bg-white border border-border input-surface relative block appearance-none px-2.5 h-10 text-sm rounded-[10px] transition-colors',
props.readOnly && 'bg-muted border-0 text-muted-foreground',
props.className,
props.label && 'mt-1'
)}>
{
props.placeholder &&
<option value="" disabled selected>{props.placeholder}</option>
}
{
props.items?.map((item) => {
return (
<option key={item.value} value={item.value}>
{item.label}
</option>
)
})
}
</select>
<ArrowDown2 size={16} color='#8c90a3' className='absolute z-0 top-3 left-2 pointer-events-none'
/>
</div>
{
props.error_text && props.error_text !== '' ?
<div className='text-xs text-right text-red-600 mt-2 mr-2 font-medium'>
{props.error_text}
</div>
: null
}
</div>
)
}
export default Select
+15
View File
@@ -0,0 +1,15 @@
import { type FC } from 'react'
type Props = {
color: string
}
const StatusCircle: FC<Props> = (props: Props) => {
return (
<div style={{ background: props.color }} className='size-1.5 rounded-full'>
</div>
)
}
export default StatusCircle
+15
View File
@@ -0,0 +1,15 @@
import { type FC } from 'react'
type Props = {
color: string
}
const StatusCircle: FC<Props> = (props: Props) => {
return (
<div style={{ background: props.color }} className='size-1.5 rounded-full'>
</div>
)
}
export default StatusCircle
+363
View File
@@ -0,0 +1,363 @@
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';
import { Checkbox } from '@/components/ui/checkbox';
import RowActionsDropdown from '@/components/RowActionsDropdown';
import Pagination from '@/components/Pagination';
const Table = <T extends RowDataType>({
columns,
data,
isLoading = false,
onRowClick,
className = '',
rowClassName = '',
noDataMessage = 'هیچ داده‌ای یافت نشد',
headerClassName = 'bg-gray-50',
emptyRowsCount = 5,
showHeader = true,
actions = null,
actionsPosition = 'top',
selectable = false,
selectedActions = null,
onSelectionChange,
rowActions,
pagination,
}: TableProps<T>): React.ReactElement => {
const [selectedRows, setSelectedRows] = useState<T[]>([]);
const getRowClassName = (item: T, index: number): string => {
if (typeof rowClassName === 'function') {
return rowClassName(item, index);
}
return rowClassName;
};
const getCellClassName = (column: ColumnType<T>): string => {
const alignClass = column.align ? `text-${column.align}` : '';
return `px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${alignClass} ${column.className || ''}`.trim();
};
const handleRowClick = (item: T) => {
if (onRowClick) {
onRowClick(item.id, item);
}
};
const handleSelectAll = () => {
const dataArray = data || [];
const newSelectedRows = selectedRows.length === dataArray.length ? [] : [...dataArray];
setSelectedRows(newSelectedRows);
if (onSelectionChange) {
onSelectionChange(newSelectedRows);
}
};
const handleSingleRowSelect = (item: T, checked: boolean) => {
const newSelectedRows = checked
? [...selectedRows, item]
: selectedRows.filter((row) => row.id !== item.id);
setSelectedRows(newSelectedRows);
if (onSelectionChange) {
onSelectionChange(newSelectedRows);
}
};
const isRowSelected = (item: T): boolean => {
return selectedRows.some((row) => row.id === item.id);
};
// Gmail-style layout برای وقتی header نمایش داده نمی‌شود
const renderGmailStyleRows = () => {
const hasActions = (actionsPosition === 'top' || actionsPosition === 'above-header') && (actions || (selectable && selectedRows.length > 0 && selectedActions));
const roundedClass = hasActions ? 'rounded-b-2xl' : 'rounded-2xl';
if (isLoading) {
return (
<div className={`bg-white ${roundedClass} overflow-hidden`}>
{Array.from({ length: emptyRowsCount }).map((_, index) => (
<div key={index} className={`h-[48px] bg-gray-100 animate-pulse ${index !== 0 ? 'border-t border-[#EAEDF5]' : ''}`}></div>
))}
</div>
);
}
if (!data || data.length === 0) {
return (
<div className={`bg-white ${roundedClass} py-6 md:py-8 text-center text-gray-500`}>
{noDataMessage}
</div>
);
}
return (
<div className={`bg-white ${roundedClass} overflow-hidden`}>
{data.map((item, rowIndex) => (
<div
key={item.id}
className={`w-full min-h-[48px] hover:bg-[#F4F5F9] ${onRowClick ? 'cursor-pointer' : ''} ${getRowClassName(item, rowIndex)} px-4 py-3 flex items-center gap-3 ${rowIndex !== 0 ? 'border-t border-[#EAEDF5]' : ''}`}
onClick={() => handleRowClick(item)}
>
{selectable && (
<div
className="flex-shrink-0 relative z-[5]"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={isRowSelected(item)}
onCheckedChange={(checked) => handleSingleRowSelect(item, !!checked)}
/>
</div>
)}
<div className="flex-1 min-w-0">
{/* محتوای اصلی - ستون اول */}
<div className="flex items-center mb-0.5 text-sm font-medium">
{columns[0]?.render ? columns[0].render(item, rowIndex) : (item[columns[0]?.key] as React.ReactNode)}
</div>
{/* اطلاعات اضافی - سه ستون آخر در یک خط */}
{columns.length > 1 && (
<div className="flex items-center gap-3 text-xs text-gray-500">
{columns.slice(1).map((col) => (
<div key={col.key} className="truncate">
{col.render ? col.render(item, rowIndex) : (item[col.key] as React.ReactNode)}
</div>
))}
</div>
)}
</div>
{rowActions && (
<div
className="flex-shrink-0 relative z-[5]"
onClick={(e) => e.stopPropagation()}
>
<RowActionsDropdown actions={rowActions(item)} />
</div>
)}
</div>
))}
</div>
);
};
const renderTableBody = () => {
if (isLoading) {
return (
<Fragment>
<DefaultTableSkeleton tdCount={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} trCount={emptyRowsCount} />
</Fragment>
);
}
if (!data || data.length === 0) {
return (
<tr>
<td colSpan={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} className="px-3 md:px-6 py-6 md:py-8 text-center text-gray-500">
{noDataMessage}
</td>
</tr>
);
}
return (data || []).map((item, rowIndex) => (
<tr
key={item.id}
className={`w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5] ${onRowClick ? 'cursor-pointer' : ''} ${getRowClassName(item, rowIndex)}`}
onClick={() => handleRowClick(item)}
>
{selectable && (
<td
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 relative z-[5]"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={isRowSelected(item)}
onCheckedChange={(checked) => handleSingleRowSelect(item, !!checked)}
/>
</td>
)}
{columns.map((col) => (
<td
key={`${item.id}-${col.key}`}
className={getCellClassName(col)}
style={{ width: col.width }}
>
{col.render ? col.render(item, rowIndex) : item[col.key] as React.ReactNode}
</td>
))}
{rowActions && (
<td
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 relative z-[5]"
onClick={(e) => e.stopPropagation()}
>
<RowActionsDropdown actions={rowActions(item)} />
</td>
)}
</tr>
));
};
const renderHeader = () => {
if (!showHeader || actionsPosition === 'header-replace') return null;
return (
<thead className={`h-[60px] md:h-[69px] bg-white text-sm text-[#8C90A3] ${headerClassName}`}>
<tr>
{selectable && (
<th
className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 relative z-[5] first:rounded-tr-2xl md:first:rounded-tr-3xl"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={(data?.length || 0) > 0 && selectedRows.length === (data?.length || 0)}
onCheckedChange={handleSelectAll}
/>
</th>
)}
{columns.map((col) => (
<Td key={col.key} text={col.title} />
))}
{rowActions && (
<th className="px-3 md:px-6 py-3 md:py-4 w-8 md:w-10 rounded-tl-2xl md:rounded-tl-3xl"></th>
)}
</tr>
</thead >
);
};
const renderActions = () => {
if (!actions && (!selectable || selectedRows.length === 0 || !selectedActions)) return null;
return (
<div className="h-[64px] rounded-t-2xl md:rounded-t-3xl md:h-[74px] bg-white flex items-center px-3 md:px-6">
<div className='flex items-center gap-2 md:gap-4'>
{actions}
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
</div>
</div>
);
};
// اگر header نشان داده نمی‌شود، از Gmail-style layout فقط در موبایل استفاده کن
if (!showHeader) {
return (
<div className={`relative mt-6 md:mt-9 w-full ${className}`}>
{/* نسخه موبایل - Gmail style */}
<div className={`md:hidden`}>
{(actionsPosition === 'top' || actionsPosition === 'above-header') && (
<div className="h-[64px] bg-white flex items-center px-4 rounded-t-2xl border-b border-[#EAEDF5]">
<div className='flex items-center gap-2'>
{actions}
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
</div>
</div>
)}
{renderGmailStyleRows()}
</div>
{/* نسخه دسکتاپ - Table معمولی */}
<div className={`hidden md:block`}>
{(actionsPosition === 'top' || actionsPosition === 'above-header') && (
<div className="h-[74px] bg-white flex items-center px-6 rounded-t-3xl">
<div className='flex items-center gap-4'>
{actions}
{selectable && selectedRows.length > 0 && selectedActions && selectedActions}
</div>
</div>
)}
<div className={`overflow-x-auto overflow-hidden ${(actionsPosition === 'top' || actionsPosition === 'above-header') && (actions || (selectable && selectedRows.length > 0 && selectedActions)) ? 'rounded-b-3xl' : 'rounded-3xl'} bg-white`}>
<table className="w-full text-sm border-collapse min-w-[600px]">
<tbody>
{renderTableBody()}
</tbody>
</table>
</div>
</div>
{pagination && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
/>
)}
</div>
);
}
return (
<div className={`relative mt-6 md:mt-9 w-full ${className}`}>
{(actionsPosition === 'top' || actionsPosition === 'above-header') && renderActions()}
<div className={`overflow-x-auto overflow-hidden ${showHeader && actionsPosition !== 'header-replace' ? 'rounded-2xl md:rounded-3xl' : 'rounded-b-2xl md:rounded-b-3xl'} bg-white`}>
<table className="w-full text-sm border-collapse min-w-[600px]">
{renderHeader()}
{actionsPosition === 'header-replace' && (
<thead>
<tr>
<th colSpan={columns.length} className="p-0">
{renderActions()}
</th>
</tr>
</thead>
)}
<tbody>
{renderTableBody()}
</tbody>
</table>
</div>
{pagination && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
/>
)}
</div>
);
};
export default memo(Table) as <T extends RowDataType>(props: TableProps<T>) => React.ReactElement;
/*
مثال استفاده با pagination:
import Table from '@/components/Table';
import { useState } from 'react';
const MyComponent = () => {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 10;
const columns = [
{ title: 'نام', key: 'name' },
{ title: 'ایمیل', key: 'email' },
];
const data = [
{ id: 1, name: 'علی', email: 'ali@example.com' },
{ id: 2, name: 'فاطمه', email: 'fateme@example.com' },
];
const handlePageChange = (page: number) => {
setCurrentPage(page);
// اینجا می‌توانید API call جدید برای دریافت داده‌های صفحه جدید بزنید
};
return (
<Table
columns={columns}
data={data}
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange
}}
/>
);
};
*/
+41
View File
@@ -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> = (props) => {
const { items, activeTab, onTabChange } = props
return (
<div className='flex justify-center text-sm'>
<div className='flex gap-4 h-[52px] rounded-full bg-white px-4 items-center'>
{
items.map((item) => {
return (
<div onClick={() => 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}
</div>
)
})
}
</div>
</div>
)
}
export default Tabs
+23
View File
@@ -0,0 +1,23 @@
import type { FC, ReactNode } from 'react'
interface Props {
text: string | number | ReactNode,
children?: ReactNode,
dir?: string,
className?: string,
}
const Td: FC<Props> = (props: Props) => {
return (
<td className={`px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs ${props.className}`} style={{ direction: props.dir === "ltr" ? "ltr" : "rtl" }}>
{
props.text ?
props.text
:
props.children
}
</td>
)
}
export default Td
+67
View File
@@ -0,0 +1,67 @@
import { CloseCircle, InfoCircle, TickCircle } from 'iconsax-react';
import React, { useState, useEffect } from 'react';
import { setAddToast } from '../shared/toast';
interface Toast {
id: string;
message: string;
type?: 'success' | 'error' | 'info';
isExiting?: boolean;
}
const ToastContainer: React.FC = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
// ثبت toast جدید
const showToast = (toast: Toast) => {
setToasts((prev) => [...prev, toast]);
// حذف خودکار بعد از ۳ ثانیه
setTimeout(() => {
setToasts((prev) => prev.map(t =>
t.id === toast.id ? { ...t, isExiting: true } : t
));
// حذف از DOM بعد از اتمام انیمیشن
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== toast.id));
}, 300); // مدت زمان انیمیشن خروج
}, 3000);
};
// تخصیص تابع نمایش toast به متغیر سراسری
useEffect(() => {
setAddToast(showToast);
}, []);
return (
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`px-4 flex items-center gap-2 backdrop-blur-2xl h-16 min-w-[300px] rounded-2xl shadow-md
${toast.isExiting ? 'animate-slide-out-right' : 'animate-slide-in-right'} ${toast.type === 'success'
? 'bg-white/70 text-black'
: toast.type === 'error'
? 'bg-white/70 text-black'
: 'bg-white/70 text-black'
}`}
style={{
animationFillMode: 'forwards',
animationDuration: '0.3s',
animationTimingFunction: 'ease-out'
}}
>
{
toast.type === 'success' ? <TickCircle className='size-5' color='green' /> :
toast.type === 'error' ? <CloseCircle className='size-5' color='red' /> :
<InfoCircle className='size-5' color='blue' />
}
{toast.message}
</div>
))}
</div>
);
};
export default ToastContainer;
+90
View File
@@ -0,0 +1,90 @@
import { type FC, useCallback, useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import { CloseCircle } from 'iconsax-react'
import { t } from '@/locale';
type Props = {
label: string,
isMultiple?: boolean,
onChange?: (file: File[]) => void;
isReset?: boolean;
}
const UploadBox: FC<Props> = (props: Props) => {
const [files, setFiles] = useState<File[]>([])
const onDrop = useCallback((acceptedFiles: File[]) => {
if (props.isMultiple) {
const array = [...files]
array.push(acceptedFiles[0])
setFiles(array)
props.onChange?.(array)
} else {
setFiles([acceptedFiles[0]])
props.onChange?.([acceptedFiles[0]])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files])
const { getRootProps, getInputProps } = useDropzone({ onDrop })
const handleRemove = (index: number) => {
const array = [...files]
array.splice(index, 1)
setFiles(array)
props.onChange?.(array)
}
useEffect(() => {
if (props.isReset) {
setFiles([])
}
}, [props.isReset])
return (
<div>
<div className='text-sm'>{props.label}</div>
<div className='w-full h-10 mt-1 border border-border rounded-xl items-center px-2 flex gap-2.5'>
<button {...getRootProps()} className=' w-fit h-7 rounded-lg text-xs px-6 bg-secondary'>
<input {...getInputProps()} />
{t('uploadBox.selectFile')}
</button>
<div className='lg:flex gap-2 hidden'>
{
files.length === 0 ? (
<div className='text-xs text-description'>هیچ فایلی انتخاب نشده است</div>
) : (
files?.map((item, index) => (
<div key={index} className='flex bg-gray-200 py-1.5 px-2 rounded-lg items-center gap-1'>
<CloseCircle onClick={() => handleRemove(index)} size={16} color='red' />
<div className='text-xs'>{item.name}</div>
</div>
))
)
}
</div>
</div>
{files.length > 0 && (
<div className='lg:hidden gap-2 flex flex-wrap mt-4'>
{
files?.map((item, index) => (
<div key={index} className='flex bg-gray-200 py-1.5 px-2 rounded-lg items-center gap-1'>
<CloseCircle onClick={() => handleRemove(index)} size={16} color='red' />
<div className='text-xs'>{item.name}</div>
</div>
))
}
</div>
)}
</div>
)
}
export default UploadBox
+189
View File
@@ -0,0 +1,189 @@
import { type FC, useState, useEffect, useRef } from 'react'
import { Microphone, Play, Pause } from 'iconsax-react'
type Props = {
label?: string
placeholder?: string
value?: string
onChange?: (value: string) => void
onRecordingComplete?: (audioBlob: Blob) => void
}
const VoiceRecorder: FC<Props> = ({
label = 'پیام شما',
placeholder = 'پیام شما',
value,
onChange,
onRecordingComplete
}) => {
const [isRecording, setIsRecording] = useState(false)
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const chunksRef = useRef<Blob[]>([])
const animationFrameRef = useRef<number | null>(null)
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
chunksRef.current = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' })
const url = URL.createObjectURL(audioBlob)
setAudioUrl(url)
onRecordingComplete?.(audioBlob)
stream.getTracks().forEach(track => track.stop())
}
mediaRecorder.start()
setIsRecording(true)
} catch (error) {
console.error('خطا در دسترسی به میکروفون:', error)
}
}
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop()
setIsRecording(false)
}
}
const togglePlayPause = () => {
if (!audioRef.current) return
if (isPlaying) {
audioRef.current.pause()
} else {
audioRef.current.play()
}
setIsPlaying(!isPlaying)
}
const updateProgress = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime)
if (isPlaying) {
animationFrameRef.current = requestAnimationFrame(updateProgress)
}
}
}
useEffect(() => {
if (audioUrl && audioRef.current) {
audioRef.current.addEventListener('loadedmetadata', () => {
if (audioRef.current) {
setDuration(audioRef.current.duration)
}
})
audioRef.current.addEventListener('ended', () => {
setIsPlaying(false)
setCurrentTime(0)
})
}
}, [audioUrl])
useEffect(() => {
if (isPlaying) {
updateProgress()
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPlaying])
const generateWaveBars = () => {
const bars = []
const barCount = 100
const progress = duration > 0 ? currentTime / duration : 0
for (let i = 0; i < barCount; i++) {
const position = i / barCount
const isPassed = position <= progress
const baseHeight = 3
const randomHeight = Math.random() * 20 + baseHeight
bars.push(
<div
key={i}
className="w-[2px] rounded-full transition-all duration-100"
style={{
height: `${randomHeight}px`,
backgroundColor: isPassed ? '#3B82F6' : '#E5E7EB'
}}
/>
)
}
return bars
}
return (
<div className="w-full">
{label && <div className="text-sm mb-1">{label}</div>}
<div className="w-full">
<div className="w-full h-10 bg-white border border-border rounded-xl flex items-center px-4 gap-2">
<input
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange?.(e.target.value)}
className="flex-1 bg-transparent outline-none text-sm"
/>
<button
onClick={isRecording ? stopRecording : startRecording}
className="flex-shrink-0"
>
<Microphone
size={20}
color={isRecording ? '#EF4444' : '#000'}
variant={isRecording ? 'Bold' : 'Outline'}
/>
</button>
</div>
{audioUrl && (
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3">
<button
onClick={togglePlayPause}
className="w-8 h-8 rounded-full bg-black flex items-center justify-center flex-shrink-0"
>
{isPlaying ? (
<Pause size={16} color="#fff" variant="Bold" />
) : (
<Play size={16} color="#fff" variant="Bold" />
)}
</button>
<div className="flex-1 flex items-center gap-[2px] h-8">
{generateWaveBars()}
</div>
<audio ref={audioRef} src={audioUrl} className="hidden" />
</div>
)}
</div>
</div>
)
}
export default VoiceRecorder
+47
View File
@@ -0,0 +1,47 @@
export interface RowDataType {
id: string | number;
[key: string]: unknown;
}
export interface ColumnType<T extends RowDataType> {
title: string;
key: string;
align?: "left" | "center" | "right";
className?: string;
width?: string | number;
render?: (item: T, index?: number) => React.ReactNode;
}
export interface PaginationType {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export interface ActionType {
label: string;
onClick: () => void;
icon?: React.ReactNode;
className?: string;
disabled?: boolean;
}
export interface TableProps<T extends RowDataType> {
columns: ColumnType<T>[];
data?: T[];
isLoading?: boolean;
onRowClick?: (id: string | number, item: T) => void;
className?: string;
rowClassName?: string | ((item: T, index: number) => string);
noDataMessage?: string;
headerClassName?: string;
emptyRowsCount?: number;
showHeader?: boolean;
actions?: React.ReactNode;
actionsPosition?: "top" | "above-header" | "bottom" | "header-replace";
selectable?: boolean;
selectedActions?: React.ReactNode;
onSelectionChange?: (selectedRows: T[]) => void;
rowActions?: (item: T) => ActionType[];
pagination?: PaginationType;
}
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { clx } from "@/helpers/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={clx(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={clx("flex items-center justify-center text-current")}
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20,6 9,17 4,12" />
</svg>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+21
View File
@@ -0,0 +1,21 @@
export const Paths = {
requests: {
list: '/requests',
},
home: '/home',
myOrders: '/my-orders',
proformaInvoice: '/proforma-invoice',
newOrder: '/new-order',
orderDetails: '/order/',
tickets: {
list: '/tickets',
},
announcement: {
list: '/announcement',
},
criticisms: '/criticisms',
learning: '/learning',
auth: {
login: '/auth/login',
},
}
+33
View File
@@ -0,0 +1,33 @@
import axios from "axios";
import { getToken } from "./func";
const instance = axios.create({
baseURL: import.meta.env.VITE_API_URL || "http://localhost:3000/api",
timeout: 10000,
});
instance.interceptors.request.use(
(config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized
console.log("Unauthorized");
}
return Promise.reject(error);
}
);
export default instance;
+47
View File
@@ -0,0 +1,47 @@
const TOKEN_NAME = import.meta.env.VITE_TOKEN_NAME;
const REFRESH_TOKEN_NAME = import.meta.env.VITE_REFRESH_TOKEN_NAME;
export const getToken = () => {
return localStorage.getItem(TOKEN_NAME);
};
export const setToken = (token: string) => {
localStorage.setItem(TOKEN_NAME, token);
};
export const removeToken = () => {
localStorage.removeItem(TOKEN_NAME);
};
export const getRefreshToken = () => {
return localStorage.getItem(REFRESH_TOKEN_NAME);
};
export const setRefreshToken = (refreshToken: string) => {
localStorage.setItem(REFRESH_TOKEN_NAME, refreshToken);
};
export const removeRefreshToken = () => {
localStorage.removeItem(REFRESH_TOKEN_NAME);
};
export const timeAgo = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
const diffInHours = Math.floor(diffInMinutes / 60);
const diffInDays = Math.floor(diffInHours / 24);
if (diffInMinutes < 1) return "همین الان";
if (diffInMinutes < 60) return `${diffInMinutes} دقیقه پیش`;
if (diffInHours < 24) return `${diffInHours} ساعت پیش`;
if (diffInDays < 7) return `${diffInDays} روز پیش`;
return date.toLocaleDateString("fa-IR");
};
export const NumberFormat = (num?: number): string => {
if (!num) return "0";
return num.toLocaleString("fa-IR");
};
+14
View File
@@ -0,0 +1,14 @@
export const COLORS = {
primary: "#ffa800",
border: "#f5f7fc",
description: "#c3c7dd",
gray: "#4F5260",
grayLight: "#8C90A3",
success: "#00BA4B",
error: "red",
info: "blue",
black: "black",
white: "#fff",
} as const;
export type ColorKey = keyof typeof COLORS;
+20
View File
@@ -0,0 +1,20 @@
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> = T | U extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U;
export type ItemsSelectType = {
value: string;
label: string;
};
export type ErrorType = Error & {
response?: {
data?: {
error: {
message: string[];
};
};
};
};
+11
View File
@@ -0,0 +1,11 @@
import { twMerge } from "tailwind-merge";
/**
* Concatenates truthy classes into a space-separated string.
*
* @param classes - The classes to concatenate.
* @returns The concatenated classes.
*/
export const clx = (...classes: (string | boolean | undefined)[]): string => {
return twMerge(classes.filter(Boolean).join(" "));
};
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useRef } from 'react';
export const useOutsideClick = (callback: () => void) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
document.addEventListener('mouseup', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
return () => {
document.removeEventListener('mouseup', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [callback]);
return ref;
};
+77
View File
@@ -0,0 +1,77 @@
@import "tailwindcss";
html {
direction: rtl;
overflow: hidden;
max-width: 100%;
}
body {
font-family: irancell;
margin: 0;
padding: 0;
width: 100%;
direction: rtl;
overflow: hidden !important;
background: #eceef6;
font-weight: 300;
overflow: hidden;
font-feature-settings: "lnum"; /* اطمینان از نمایش اعداد انگلیسی */
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden !important; /* اعمال overflow: hidden با !important */
}
html,
body,
#root {
min-height: 100% !important;
height: 100%;
}
* {
outline: none;
}
input::placeholder,
textarea::placeholder {
color: #888888;
}
@theme {
--color-primary: #ffa800;
--color-border: #f5f7fc;
--color-secondary: #ebedf5;
--color-description: #c3c7dd;
--color-desc: #8c90a3;
}
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%;
}
.rowTwoInput {
@apply flex flex-col items-start sm:flex-row sm:gap-5 gap-5;
}
+45
View File
@@ -0,0 +1,45 @@
export const fa = {
sidebar: {
menu: "منو",
mainPage: "صفحه اصلی",
other: "سایر",
logout: "خروج",
myOrders: "سفارشات من",
preFactors: "پیش فاکتورها",
tickets: "تیکت ها",
announcement: "انتشارات",
criticisms: "انتقادات",
learning: "آموزش",
submitYourOrder: "سفارش خودرا ثبت کنید",
submitYourOrderDescription: "کارت ویزیت ، کاتالوگ ، بروشور و...",
newOrder: "سفارش جدید",
},
notif: {
natification: "اعلان‌ها",
all_read: "خواندن همه",
},
header: {
search: "جستجو",
},
home: {
requestCount: "تعداد درخواست ها",
factureCount: "پیش فاکتور تایید نشده",
orderCount: "سفارش های در حال انجام",
orderDoneCount: "سفارشات انجام شده",
services: "خدمات",
submitNewOrder: "سفارش خودرا ثبت کنید",
submitNewOrderButton: "سفارش جدید",
specialSolution: "راه‌حل اختصاصی برای شما در نگاره!",
specialSolutionDescription: "چاپ سریع، دقیق و خلاقانه برای کسب‌وکار شما!",
specialSolutionFeature1: "متناسب با حوزه‌ی فعالیت شما و نیازهای برندتان",
specialSolutionFeature2:
"با بیش از ۲۸ سال تجربه‌ی حرفه‌ای در چاپ و تبلیغات",
specialSolutionFeature3: "رعایت بهترین استانداردهای کیفیت و طراحی",
freeConsultation: "دریافت مشاوره رایگان",
contactUs: "با ما تماس بگیرید",
phoneNumber: "۰۸۶۹۱۰۰۹۰۰۱",
},
uploadBox: {
selectFile: "انتخاب فایل",
},
};
+39
View File
@@ -0,0 +1,39 @@
import { fa } from "./fa";
export type LocaleKey = "fa";
export interface TranslationsType {
[key: string]: string | TranslationsType;
}
const locales: Record<LocaleKey, TranslationsType> = { fa };
let currentLang: LocaleKey = "fa";
export const setLang = (lang: LocaleKey) => {
currentLang = lang;
};
export const getLang = (): LocaleKey => currentLang;
const cache = new Map<string, string>();
export const t = (key: string, fallback?: string): string => {
const cacheKey = `${currentLang}:${key}`;
if (cache.has(cacheKey)) return cache.get(cacheKey)!;
const keys = key.split(".");
let value: string | TranslationsType | undefined = locales[currentLang];
for (const k of keys) {
if (typeof value === "object" && value !== null) {
value = value[k];
} else {
value = undefined;
break;
}
}
const result = typeof value === "string" ? value : fallback || key;
cache.set(cacheKey, result);
return result;
};
+7
View File
@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<App />
)
+80
View File
@@ -0,0 +1,80 @@
import { type FC } from 'react'
import Stats from './components/Stats'
import Services from './components/Services'
import Orders from './components/Orders'
import NewOrderImage from '@/assets/images/new_order.png'
import { t } from '@/locale'
import Button from '@/components/Button'
import { AddSquare, MessageQuestion } from 'iconsax-react'
import { COLORS } from '@/constants/colors'
import SupportImage from '@/assets/images/support1.png'
const Home: FC = () => {
return (
<div className='flex gap-6'>
<div className='flex-1'>
<Stats />
<Services />
<Orders />
</div>
<div className='min-w-[267px] mt-5'>
<div className='bg-white rounded-3xl p-6'>
<img src={NewOrderImage} className='w-[98px] mx-auto' />
<h4 className='mt-4 text-center font-medium'>
{t('home.submitNewOrder')}
</h4>
<Button
className='mt-2.5'>
<div className='flex gap-1'>
<AddSquare size={20} color='#292D32' />
<div className=''>{t('home.submitNewOrderButton')}</div>
</div>
</Button>
</div>
<div className='mt-7 bg-white rounded-3xl p-6 relative'>
<h4>
{t('home.specialSolution')}
</h4>
<p className='mt-2 text-[#7B7E8B] text-xs'>
{t('home.specialSolutionDescription')}
</p>
<div className='mt-2 flex flex-col gap-2'>
<div className='flex gap-2 items-center text-xs'>
<div className='size-1.5 rounded-full bg-primary'></div>
<div>{t('home.specialSolutionFeature1')}</div>
</div>
<div className='flex gap-2 items-center text-xs'>
<div className='size-1.5 rounded-full bg-primary'></div>
<div>{t('home.specialSolutionFeature2')}</div>
</div>
<div className='flex gap-2 items-center text-xs'>
<div className='size-1.5 rounded-full bg-primary'></div>
<div>{t('home.specialSolutionFeature3')}</div>
</div>
</div>
<Button
label={t('home.freeConsultation')}
className='mt-12'
/>
<div className='mt-2.5 bg-primary/11 py-6 px-4 rounded-[13px]'>
<div className='flex justify-between items-center'>
<div className='flex gap-2 items-center'>
<MessageQuestion size={20} color={COLORS.primary} />
<div className='text-xs font-light'>{t('home.contactUs')}</div>
</div>
<div>{t('home.phoneNumber')}</div>
</div>
</div>
<img src={SupportImage} className='w-[50px] absolute bottom-[159px] left-2' />
</div>
</div>
</div>
)
}
export default Home
@@ -0,0 +1,106 @@
import { type FC } from 'react';
import { Eye } from 'iconsax-react';
import StatusCircle from '@/components/StatusCircle';
import { type Order, type OrderStatus } from './types/OrderTypes';
// کامپوننت رندر شماره سفارش
export const OrderNumberRenderer: FC<{ order: Order }> = ({ order }) => {
return (
<span className="text-sm font-medium text-gray-900">
{order.number}
</span>
);
};
// کامپوننت رندر عنوان سفارش
export const OrderTitleRenderer: FC<{ order: Order }> = ({ order }) => {
return (
<span className="text-sm font-medium text-gray-900">
{order.title}
</span>
);
};
// کامپوننت رندر تاریخ
export const OrderDateRenderer: FC<{ order: Order }> = ({ order }) => {
return (
<div className="text-xs text-gray-500">
<div className="font-medium">
({order.creationDate.gregorian})
</div>
<div>
{order.creationDate.persian}
</div>
</div>
);
};
// کامپوننت رندر وضعیت سفارش
export const OrderStatusRenderer: FC<{ order: Order }> = ({ order }) => {
const getStatusConfig = (status: OrderStatus) => {
switch (status) {
case 'در حال طراحی':
return {
color: 'text-blue-600',
bgColor: 'bg-blue-50'
};
case 'تهیه متریال':
return {
color: 'text-gray-600',
bgColor: 'bg-gray-50'
};
case 'در حال چاپ':
return {
color: 'text-orange-600',
bgColor: 'bg-orange-50'
};
case 'تکمیل شده':
return {
color: 'text-green-600',
bgColor: 'bg-green-50'
};
default:
return {
color: 'text-gray-600',
bgColor: 'bg-gray-50'
};
}
};
const config = getStatusConfig(order.status);
return (
<span className={`text-sm font-medium ${config.color}`}>
{order.status}
</span>
);
};
// کامپوننت رندر پیام‌ها
export const OrderMessagesRenderer: FC<{ order: Order }> = ({ order }) => {
const hasUnreadMessages = order.unreadMessages > 0;
return (
<div className="flex items-center gap-2">
<StatusCircle color={hasUnreadMessages ? '#EF4444' : '#3B82F6'} />
<span className="text-xs text-gray-500">
{order.unreadMessages} پیام خوانده نشده
</span>
</div>
);
};
// کامپوننت رندر آیکون مشاهده
export const OrderViewRenderer: FC<{ order: Order }> = ({ order }) => {
return (
<button
className="p-1 hover:bg-gray-100 rounded-full transition-colors"
onClick={() => {
// اینجا می‌توانید منطق مشاهده جزئیات را اضافه کنید
console.log('View order:', order.id);
}}
>
<Eye color='black' size={16} />
</button>
);
};
+64
View File
@@ -0,0 +1,64 @@
import Table from '@/components/Table'
import { type FC } from 'react'
import { type ColumnType } from '@/components/types/TableTypes'
import { type Order } from './types/OrderTypes'
import { ordersData } from './data/ordersData'
import {
OrderNumberRenderer,
OrderTitleRenderer,
OrderDateRenderer,
OrderStatusRenderer,
OrderMessagesRenderer,
OrderViewRenderer
} from './OrderTableComponents'
const Orders: FC = () => {
const columns: ColumnType<Order>[] = [
{
title: 'شماره',
key: 'number',
render: (order) => <OrderNumberRenderer order={order} />
},
{
title: 'عنوان سفارش',
key: 'title',
render: (order) => <OrderTitleRenderer order={order} />
},
{
title: 'تاریخ ایجاد',
key: 'creationDate',
render: (order) => <OrderDateRenderer order={order} />
},
{
title: 'وضعیت سفارش',
key: 'status',
render: (order) => <OrderStatusRenderer order={order} />
},
{
title: 'پیام ها',
key: 'unreadMessages',
render: (order) => <OrderMessagesRenderer order={order} />
},
{
title: '',
key: 'actions',
render: (order) => <OrderViewRenderer order={order} />,
width: '60px'
}
]
return (
<div className='mt-6 bg-white p-6 rounded-3xl'>
<h4 className='text-lg font-light'>
سفارش های در حال انجام
</h4>
<Table<Order>
columns={columns}
data={ordersData}
/>
</div>
)
}
export default Orders
+13
View File
@@ -0,0 +1,13 @@
import { type FC } from 'react'
const ServiceItem: FC = () => {
return (
<div>
<div className='size-20 rounded-full bg-gray-200 border-[3px] border-primary'>
</div>
</div>
)
}
export default ServiceItem
+44
View File
@@ -0,0 +1,44 @@
import { t } from '@/locale'
import { type FC } from 'react'
import { Swiper, SwiperSlide } from 'swiper/react'
import ServiceItem from './ServiceItem'
const Services: FC = () => {
return (
<div className='mt-6 bg-white p-6 rounded-3xl'>
<h4 className='text-lg font-light'>
{t('home.services')}
</h4>
<div className='mt-6'>
<Swiper
spaceBetween={16}
slidesPerView={'auto'}
className='h-full'
breakpoints={{
640: {
spaceBetween: 24,
},
}}
>
<SwiperSlide className='!w-auto'>
<ServiceItem />
</SwiperSlide>
<SwiperSlide className='!w-auto'>
<ServiceItem />
</SwiperSlide>
<SwiperSlide className='!w-auto'>
<ServiceItem />
</SwiperSlide>
<SwiperSlide className='!w-auto'>
<ServiceItem />
</SwiperSlide>
</Swiper>
</div>
</div>
)
}
export default Services
+34
View File
@@ -0,0 +1,34 @@
import { COLORS } from '@/constants/colors'
import { ArrowLeft } from 'iconsax-react'
import { type FC, type ReactNode } from 'react'
type Props = {
icon: ReactNode,
count: number,
description: string
}
const StatCard: FC<Props> = (props) => {
const { icon, count, description } = props
return (
<div className='flex-1 bg-white rounded-3xl p-6'>
<div className='flex justify-between items-center'>
{icon}
<div className='size-8 bg-[#FFF1D7] rounded-full flex justify-center items-center'>
<ArrowLeft size={16} color={COLORS.primary} className='rotate-45' />
</div>
</div>
<div className='mt-2 text-2xl font-semibold'>
{count}
</div>
<div className='mt-2 text-sm'>
{description}
</div>
</div>
)
}
export default StatCard
+53
View File
@@ -0,0 +1,53 @@
import GridWrapper from '@/components/GridWrapper'
import { COLORS } from '@/constants/colors'
import { t } from '@/locale'
import { BoxTick, BoxTime, ReceiptText, TruckTick } from 'iconsax-react'
import { type FC } from 'react'
import StatCard from './StatCard'
const Stats: FC = () => {
return (
<GridWrapper
desktop={4}
mobile={2}
gapDesktop={24}
gapMobile={12}
className='mt-5'
>
<StatCard
count={2}
description={t('home.requestCount')}
icon={<BoxTime
size={27}
color={COLORS.primary}
/>}
/>
<StatCard
count={2}
description={t('home.factureCount')}
icon={<ReceiptText
size={27}
color={COLORS.primary}
/>}
/>
<StatCard
count={10}
description={t('home.orderCount')}
icon={<BoxTick
size={27}
color={COLORS.primary}
/>}
/>
<StatCard
count={20}
description={t('home.orderDoneCount')}
icon={<TruckTick
size={27}
color={COLORS.primary}
/>}
/>
</GridWrapper>
)
}
export default Stats
@@ -0,0 +1,37 @@
import { type Order } from '../types/OrderTypes';
export const ordersData: Order[] = [
{
id: 1,
number: '123455',
title: 'کارت ویزیت',
creationDate: {
gregorian: '2024-11-01',
persian: '1403/09/01'
},
status: 'در حال طراحی',
unreadMessages: 2
},
{
id: 2,
number: '123455',
title: 'شاپینگ بگ',
creationDate: {
gregorian: '2024-11-01',
persian: '1403/09/01'
},
status: 'تهیه متریال',
unreadMessages: 0
},
{
id: 3,
number: '123455',
title: 'کارت ویزیت',
creationDate: {
gregorian: '2024-11-01',
persian: '1403/09/01'
},
status: 'در حال چاپ',
unreadMessages: 2
}
];
@@ -0,0 +1,22 @@
import { type RowDataType } from '@/components/types/TableTypes';
export interface Order extends RowDataType {
id: string | number;
number: string;
title: string;
creationDate: {
gregorian: string;
persian: string;
};
status: OrderStatus;
unreadMessages: number;
[key: string]: unknown;
}
export type OrderStatus = 'در حال طراحی' | 'تهیه متریال' | 'در حال چاپ' | 'تکمیل شده';
export interface OrderStatusConfig {
status: OrderStatus;
color: string;
textColor: string;
}
+104
View File
@@ -0,0 +1,104 @@
import Tabs from '@/components/Tabs'
import { useState, type FC } from 'react'
import { ProformaInvoiceStatusEnum } from './enum/InvoiceEnum'
import Filters from '@/components/Filters'
import Table from '@/components/Table'
const ProformaInvoice: FC = () => {
const [activeTab, setactiveTab] = useState<ProformaInvoiceStatusEnum>(ProformaInvoiceStatusEnum.ALL)
return (
<div className='mt-5'>
<h1>
پیش فاکتورها
</h1>
<div className='mt-8'>
<Tabs
items={[
{ label: 'همه', value: 'all' },
{ label: 'تایید نشده', value: 'not_confirmed' },
{ label: 'تایید شده', value: 'confirmed' },
]}
activeTab={activeTab}
onTabChange={(tab) => setactiveTab(tab as ProformaInvoiceStatusEnum)}
/>
</div>
<div className='mt-8'>
<Filters
fields={[
{
type: 'input',
name: 'search',
placeholder: 'جستجو',
},
{
type: 'date',
name: 'date',
placeholder: 'تاریخ',
},
]}
onChange={() => { }}
/>
</div>
<div className='mt-8'>
<Table
columns={[
{
key: 'id',
title: 'شماره',
},
{
key: 'service',
title: 'خدمت',
},
{
key: 'issueDate',
title: 'تاریخ صدور',
},
{
key: 'lastConfirmDate',
title: 'آخرین مهلت تایید',
},
{
key: 'total',
title: 'مجموع',
},
{
key: 'confirmStatus',
title: 'وضعیت تایید',
},
{
key: 'paymentStatus',
title: 'وضعیت پرداخت',
}
]}
data={[
{
id: 1,
service: 'خدمت 1',
issueDate: '2021-01-01',
lastConfirmDate: '2021-01-01',
total: 100000,
confirmStatus: 'تایید شده',
paymentStatus: 'پرداخت شده',
},
{
id: 2,
service: 'خدمت 2',
issueDate: '2021-01-01',
lastConfirmDate: '2021-01-01',
total: 100000,
confirmStatus: 'تایید شده',
paymentStatus: 'پرداخت شده',
},
]}
/>
</div>
</div>
)
}
export default ProformaInvoice
+5
View File
@@ -0,0 +1,5 @@
export const enum ProformaInvoiceStatusEnum {
ALL = 'all',
NOT_CONFIRMED = 'not_confirmed',
CONFIRMED = 'confirmed',
}
+149
View File
@@ -0,0 +1,149 @@
import { Notification } from 'iconsax-react';
import { type FC, useState } from 'react';
import XIcon from '../../assets/images/close-circle.svg';
import { clx } from '../../helpers/utils';
import StatusCircle from '@/components/StatusCircle';
import { useOutsideClick } from '@/hooks/useOutSideClick';
import { type NotificationItemType, NotificationTypeEnum } from './types/NotificationTypes';
import Button from '@/components/Button';
import { t } from '@/locale';
const Notifications: FC = () => {
const ref = useOutsideClick(() => setShowModal(false));
const [activeTab, setActiveTab] = useState<'read' | 'unread'>('unread');
const [showModal, setShowModal] = useState<boolean>(false);
const posts: NotificationItemType[] = [];
// const getDashboard = useGetNotification('all')
const handleAllRead = () => {
// TODO: Implement read all functionality
}
const handleRedirect = (type: NotificationTypeEnum) => {
switch (type) {
case NotificationTypeEnum.USER_LOGIN:
break;
case NotificationTypeEnum.ANNOUNCEMENT:
// navigate(Pages.announcement.list)
break;
case NotificationTypeEnum.WALLET_CHARGE:
// navigate(Pages.transactions)
break;
case NotificationTypeEnum.WALLET_DEDUCTION:
// navigate(Pages.transactions)
break;
case NotificationTypeEnum.BILL_INVOICE_REMINDER:
// navigate(Pages.receipts.index)
break;
case NotificationTypeEnum.BILL_INVOICE:
// navigate(Pages.receipts.index)
break;
case NotificationTypeEnum.CREATE_INVOICE:
// navigate(Pages.receipts.index)
break;
case NotificationTypeEnum.CREATE_SERVICE:
// navigate(Pages.services.other)
break;
case NotificationTypeEnum.UNBLOCK_SERVICE:
// navigate(Pages.services.other)
break;
case NotificationTypeEnum.BLOCK_SERVICE:
// navigate(Pages.services.other)
break;
case NotificationTypeEnum.ANSWER_TICKET:
// navigate(Pages.ticket.list)
break;
case NotificationTypeEnum.CREATE_TICKET:
// navigate(Pages.ticket.list)
break;
default:
break
}
setShowModal(false)
}
return (
<div ref={ref}>
<div onClick={() => setShowModal(!showModal)} className='relative cursor-pointer'>
<Notification size={18} color="black" />
<div className="absolute top-0 right-0 -mt-1 -mr-1 rounded-full bg-red-500 text-white text-[8px] size-3 flex pt-0.5 pl-[1px] justify-center items-center">
{/* {getDashboard.data?.pages?.[0]?.data?.notificationCount || 0} */}
2
</div>
</div>
{showModal && (
<div id='notificationsContainer' className="xl:h-[calc(100%-110px)] h-[70%] p-6 z-50 xl:w-[300px] w-full xl:left-7 left-0 xl:top-[90px] top-0 overflow-y-auto xl:rounded-3xl rounded-b-3xl rounded-t-none fixed modalGlass3 ">
<div className="flex justify-between items-center">
<div>{t('notif.natification')}</div>
<div className="size-7 rounded-full bg-white bg-opacity-35 flex justify-center items-center">
<img src={XIcon} alt="close" className="w-4 h-4" onClick={() => setShowModal(false)} />
</div>
</div>
<div className="mt-6 flex h-8 border border-white border-opacity-35 text-xs rounded-lg overflow-hidden">
<div
onClick={() => setActiveTab('unread')}
className={clx(
'flex-1 border-l cursor-pointer text-white border-white border-opacity-70 bg-transparent flex justify-center items-center',
activeTab === 'unread' ? 'bg-white bg-opacity-70 text-black' : ''
)}
>
خوانده نشده
</div>
<div
onClick={() => setActiveTab('read')}
className={clx(
'flex-1 cursor-pointer text-white border-white bg-transparent flex justify-center items-center',
activeTab === 'read' ? 'bg-white bg-opacity-70 text-black' : ''
)}
>
خوانده شده
</div>
</div>
<div className='flex mt-4 justify-end'>
<Button
isLoading={false}
onClick={handleAllRead}
className={clx(
'flex-1 border bg-transparent text-xs h-7 rounded-md cursor-pointer text-white border-white border-opacity-70 flex justify-center items-center',
)}
>
{t('notif.all_read')}
</Button>
</div>
<div className="mt-6 flex flex-col gap-4 text-xs overflow-auto">
{
posts.map((item: NotificationItemType) => (
<div onClick={() => handleRedirect(item.type)} className="bg-white cursor-pointer h-fit gap-4 items-start rounded-xl bg-opacity-60 p-3 mb-4 flex" key={item.id}>
{
!item.isRead &&
<div className="mt-1">
<StatusCircle color="#00BA4B" />
</div>
}
<div>
<div className="truncate max-w-[200px]">{item.message}</div>
<div className="mt-2 flex gap-2 text-[10px] text-description">
<div>{/* timeAgo(item.createdAt) */}</div>
<div className="flex gap-1 items-center">
<StatusCircle color="#8C90A3" />
<div>{item.title}</div>
</div>
</div>
</div>
</div>
))
}
</div>
</div>
)}
</div>
);
};
export default Notifications;
@@ -0,0 +1,22 @@
import * as api from "../service/NotificationService";
import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
export const useGetNotification = (status: "all" | "read" | "unread") => {
return useInfiniteQuery({
queryKey: ["notifications", status],
queryFn: ({ pageParam = 1 }) => api.getNotifications(pageParam, status), // فراخوانی API برای گرفتن داده‌ها
initialPageParam: 1, // صفحه اول به طور پیشفرض
getNextPageParam: (lastPage) => {
const currentPage = lastPage.data?.pager.page;
const totalPages = lastPage.data?.pager.totalPages;
return currentPage < totalPages ? currentPage + 1 : undefined;
},
});
};
export const useReadAll = () => {
return useMutation({
mutationFn: () => api.readAll(),
});
};
@@ -0,0 +1,18 @@
import axios from "../../../config/axios";
export const getNotifications = async (
page: number,
status: "all" | "read" | "unread"
) => {
let query = ``;
if (status !== "all") {
query = `&isRead=${status === "read" ? 1 : 0}`;
}
const { data } = await axios.get(`/notifications?page=${page}${query}`);
return data;
};
export const readAll = async () => {
const { data } = await axios.patch(`/notifications/read-all`);
return data;
};
@@ -0,0 +1,33 @@
export type NotificationItemType = {
id: string;
title: string;
message: string;
createdAt: string;
isRead: boolean;
type: NotificationTypeEnum;
};
export const NotificationTypeEnum = {
USER_LOGIN: "USER_LOGIN",
ANNOUNCEMENT: "ANNOUNCEMENT",
// Finance category
WALLET_CHARGE: "WALLET_CHARGE",
WALLET_DEDUCTION: "WALLET_DEDUCTION",
//Invoice
BILL_INVOICE_REMINDER: "BILL_INVOICE_REMINDER",
BILL_INVOICE: "BILL_INVOICE",
CREATE_INVOICE: "CREATE_INVOICE",
// Service category
CREATE_SERVICE: "CREATE_SERVICE",
UNBLOCK_SERVICE: "UNBLOCK_SERVICE",
BLOCK_SERVICE: "BLOCK_SERVICE",
// Support category
ANSWER_TICKET: "ANSWER_TICKET",
CREATE_TICKET: "CREATE_TICKET",
} as const;
export type NotificationTypeEnum = typeof NotificationTypeEnum[keyof typeof NotificationTypeEnum];
+118
View File
@@ -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>(TabMyOrdersEnum.ALL)
return (
<div className='mt-5'>
<h1>
سفارشات من
</h1>
<div className='mt-10'>
<Tabs
items={[
{ label: 'همه', value: TabMyOrdersEnum.ALL },
{ label: 'در حال انجام', value: TabMyOrdersEnum.IN_PROGRESS },
{ label: 'تایید شده', value: TabMyOrdersEnum.CONFIRMED },
{ label: 'تحویل داده شده', value: TabMyOrdersEnum.DELIVERED },
{ label: 'کنسل شده', value: TabMyOrdersEnum.CANCELLED },
]}
activeTab={activeTab}
onTabChange={(tab) => setActiveTab(tab as TabMyOrdersEnum)}
/>
</div>
<div className='mt-16 flex justify-between items-center'>
<Filters
fields={[
{ type: 'input', name: 'search', placeholder: 'جستجو' },
{ type: 'date', name: 'date', placeholder: 'تاریخ' },
{
type: 'select', name: 'status', placeholder: 'وضعیت', options: [
{ label: 'همه', value: 'all' },
{ label: 'در حال انجام', value: 'in_progress' },
{ label: 'تایید شده', value: 'confirmed' },
{ label: 'تحویل داده شده', value: 'delivered' },
{ label: 'کنسل شده', value: 'cancelled' },
]
},
]}
onChange={() => { }}
/>
<Button
className='w-fit px-5'
>
<div className='flex items-center gap-2'>
<AddSquare
size={16}
color='#000000'
/>
<span>
سفارش جدید
</span>
</div>
</Button>
</div>
<div className='mt-10'>
<Table
columns={[
{
key: 'id',
title: 'شماره',
},
{
key: 'title',
title: 'عنوان',
},
{
key: 'createdAt',
title: 'زمان ثبت سفارش',
},
{
key: 'designer',
title: 'طراح',
},
{
key: 'type',
title: 'نوع سفارش',
},
{
key: 'itemsCount',
title: 'تعداد اقلام',
},
{
key: 'status',
title: 'وضعیت سفارش',
},
{
key: 'actions',
title: '',
}
]}
data={[
{
id: 1,
title: 'سفارش 1',
createdAt: '2021-01-01',
designer: 'طراح 1',
type: 'نوع 1',
itemsCount: 10,
status: 'در حال انجام',
},
]}
/>
</div>
</div>
)
}
export default MyOrders
+73
View File
@@ -0,0 +1,73 @@
import Button from '@/components/Button'
import Input from '@/components/Input'
import Select from '@/components/Select'
import UploadBox from '@/components/UploadBox'
import VoiceRecorder from '@/components/VoiceRecorder'
import { COLORS } from '@/constants/colors'
import { AddSquare } from 'iconsax-react'
import { type FC } from 'react'
const NewOrder: FC = () => {
return (
<div className='mt-5'>
<h1 className='text-lg font-light'>
سفارش جدید
</h1>
<div className='bg-white rounded-3xl p-6 mt-8'>
<div className='font-light'>اطلاعات سفارش</div>
<div className='mt-6'>
<Select
items={[]}
label='محصول'
placeholder='انتخاب محصول'
/>
</div>
<div className='mt-6 rowTwoInput'>
<Select
items={[]}
label='تعداد'
placeholder='انتخاب'
/>
<Input
label='انتخاب دلخواه'
placeholder='100'
/>
</div>
<div className='mt-6'>
<VoiceRecorder
label='پیام صوتی'
onRecordingComplete={(blob) => {
console.log('ضبط صدا تکمیل شد:', blob)
}}
/>
</div>
<div className='mt-6'>
<UploadBox
label='فایل های ضبط شده'
/>
</div>
<div className='mt-6 flex justify-end'>
<Button
className='bg-transparent border border-primary text-primary w-fit px-6'
>
<div className='flex gap-1'>
<AddSquare color={COLORS.primary} size={20} />
<div>
اضافه کردن
</div>
</div>
</Button>
</div>
</div>
</div>
)
}
export default NewOrder
+154
View File
@@ -0,0 +1,154 @@
import { type FC, useState } from 'react'
import { Microphone, Paperclip2, Receipt1 } from 'iconsax-react'
import Button from '@/components/Button'
import UploadBox from '@/components/UploadBox'
import Input from '@/components/Input'
const OrderDetail: FC = () => {
const [message, setMessage] = useState('')
const [files, setFiles] = useState<File[]>([])
const orderData = {
orderId: '۱۲۲۴۵',
title: 'شاپینگ بک',
designer: 'عباس حسینی',
quantity: '۱۰۰',
estimatedDate: '۱۴۰۵/۰۶/۰۶/۱۳',
description: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است، و برای شرایط فعلی تکنولوژی مورد نیاز، و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد، کتابهای زیادی در شصت و سه درصد گذشته حال و آینده، شناخت فراوان جامعه و متخصصان را می طلبد، تا با نرم افزارها شناخت بیشتری را برای طراحان رایانه ای علی الخصوص طراحان خلاق...'
}
const handleSubmit = () => {
console.log('Submit:', { message, files })
}
return (
<div className="w-full min-h-screen bg-[#eceef6] p-6">
{/* Header Section */}
<div className="flex items-start justify-between mb-6">
<div className="text-sm text-[#8C90A3]">
سفارش #{orderData.orderId}
</div>
</div>
{/* Order Information Section */}
<div className="bg-white rounded-2xl p-6 mb-6">
<div className='flex justify-between items-center'>
<h2 className="text-lg font-light mb-6">اطلاعات سفارش</h2>
<a href="#" className="flex items-center gap-2 text-[#0037FF] text-sm">
<Receipt1 size={20} color="#3B82F6" />
<span>پیش فاکتور</span>
</a>
</div>
<div className="flex items-center gap-6">
{/* Product Image */}
<div className='size-[86px] rounded-lg bg-gray-200'>
</div>
{/* Order Details */}
<div className="flex-1 flex gap-20 pr-10">
<div className='flex items-center gap-2'>
<div className="text-xs text-desc">عنوان:</div>
<div className="text-sm font-medium text-black">{orderData.title}</div>
</div>
<div className='flex items-center gap-2'>
<div className="text-xs text-desc">نام طرح:</div>
<div className="text-sm font-medium text-black">{orderData.designer}</div>
</div>
<div className='flex items-center gap-2'>
<div className="text-xs text-desc">تعداد:</div>
<div className="text-sm font-medium text-black">{orderData.quantity}</div>
</div>
<div className='flex items-center gap-2'>
<div className="text-xs text-desc">تخمین زمان:</div>
<div className="text-sm font-medium text-black">{orderData.estimatedDate}</div>
</div>
</div>
</div>
{/* Order Description */}
<div className="mt-6 pt-6 border-t border-dashed border-desc">
<div className="text-sm font-medium mb-2 text-desc">شرح سفارش:</div>
<p className="text-sm font-light text-black leading-6">
{orderData.description}
</p>
</div>
</div>
{/* Bottom Section - White Card */}
<div className="bg-white rounded-2xl p-6">
{/* Designer Name */}
<div className="mt-6">
<Input
label="نام طراح"
placeholder="عباس حسینی"
readOnly
/>
</div>
<div className='bg-[#F5F7FC] rounded-4xl rounded-tr-none mt-6 p-6 max-w-[55%]'>
<div className='text-sm font-light text-black leading-6'>
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است، و برای شرایط فعلی تکنولوژی مورد نیاز، و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد، کتابهای زیادی در شصت و سه درصد گذشته حال و آینده، شناخت فراوان جامعه و متخصصان را می طلبد،
</div>
<div className='flex justify-between mt-3'>
<div className='flex items-center gap-1.5 text-[#0047FF]'>
<Paperclip2 size={20} color="#0047FF" />
<div className='text-xs'>loremipsum.pdf</div>
</div>
</div>
</div>
<div className='flex justify-end'>'
<div className='bg-[#F5F7FC] rounded-4xl rounded-tl-none mt-6 p-6 max-w-[55%]'>
<div className='flex gap-1 text-sm mb-2'>
<div className='font-bold'>طراح : </div>
<div>عباس حسینی</div>
</div>
<div className='text-sm font-light text-black leading-6'>
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است، و برای شرایط فعلی تکنولوژی مورد نیاز، و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد، کتابهای زیادی در شصت و سه درصد گذشته حال و آینده، شناخت فراوان جامعه و متخصصان را می طلبد،
</div>
</div>
</div>
<div className="mt-6">
<div className="text-sm mb-2 text-black">پیام شما</div>
<div className="relative">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full h-40 bg-white border border-[#f5f7fc] rounded-xl p-4 text-sm resize-none outline-none"
placeholder=""
/>
<button className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center">
<Microphone size={20} color="black" />
</button>
</div>
</div>
<div className="mt-6">
<UploadBox
label="فایل های ضمیمه"
isMultiple={true}
onChange={setFiles}
/>
</div>
<div className="flex mt-10 justify-end">
<Button
label="ارسال"
onClick={handleSubmit}
className='w-[150px]'
/>
</div>
</div>
</div>
)
}
export default OrderDetail
+7
View File
@@ -0,0 +1,7 @@
export const enum TabMyOrdersEnum {
ALL = "all",
IN_PROGRESS = "in_progress",
CONFIRMED = "confirmed",
DELIVERED = "delivered",
CANCELLED = "cancelled",
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import axios from "@/config/axios";
export interface ProfileData {
user: {
firstName: string;
lastName: string;
email: string;
profilePic?: string;
};
}
export const useGetProfile = () => {
return useQuery({
queryKey: ["profile"],
queryFn: async (): Promise<{ data: ProfileData }> => {
const response = await axios.get("/profile");
return response.data;
},
});
};
+102
View File
@@ -0,0 +1,102 @@
import Filters from '@/components/Filters'
import Table from '@/components/Table'
import { type FC } from 'react'
const RequestList: FC = () => {
return (
<div className='mt-5'>
<h1 className='text-lg font-light'>درخواست ها</h1>
<div className='mt-8'>
<Filters
fields={[
{
name: 'search',
type: 'input',
placeholder: 'جستجو',
},
{
name: 'status',
type: 'select',
placeholder: 'وضعیت',
options: [
{
label: 'درحال انتظار',
value: 'pending',
},
],
},
{
name: 'date',
type: 'date',
placeholder: 'تاریخ',
}
]}
onChange={() => { }}
/>
</div>
<div className='mt-8'>
<Table
columns={[
{
key: 'id',
title: 'شماره',
},
{
key: 'customer',
title: 'مشتری',
},
{
key: 'creationDate',
title: 'تاریخ ایجاد درخواست',
},
{
key: 'lastApprovalDate',
title: 'آخرین مهلت تایید',
},
{
key: 'itemCount',
title: 'تعداد اقلام',
},
{
key: 'status',
title: 'وضعیت',
render: (item) => {
return (
<div className='h-6 w-fit flex items-center bg-[#FFEDCA] text-[#FF7B00] rounded-full px-2.5 text-xs'>
{item.status}
</div>
)
}
},
{
key: 'actions',
title: '',
},
]}
data={[
{
id: 1,
customer: 'مشتری 1',
creationDate: '2024-01-01',
lastApprovalDate: '2024-01-01',
itemCount: 10,
status: 'درحال انتظار',
},
{
id: 2,
customer: 'مشتری 2',
creationDate: '2024-01-01',
lastApprovalDate: '2024-01-01',
itemCount: 10,
status: 'تایید شده',
},
]}
/>
</div>
</div>
)
}
export default RequestList
+16
View File
@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import axios from "@/config/axios";
export interface WalletBalanceData {
balance: number;
}
export const useGetWalletBalance = () => {
return useQuery({
queryKey: ["wallet-balance"],
queryFn: async (): Promise<{ data: WalletBalanceData }> => {
const response = await axios.get("/wallet/balance");
return response.data;
},
});
};
+12
View File
@@ -0,0 +1,12 @@
import { type FC } from 'react'
import { Routes } from 'react-router-dom'
const AuthRouter: FC = () => {
return (
<Routes>
{/* <Route path="/login" element={<Login />} /> */}
</Routes>
)
}
export default AuthRouter
+39
View File
@@ -0,0 +1,39 @@
import { type FC } from 'react'
import { Route, Routes } from 'react-router-dom'
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'
import ProformaInvoice from '@/pages/invoice/ProformaInvoice'
import NewOrder from '@/pages/order/NewOrder'
import OrderDetail from '@/pages/order/OrderDetail'
import RequestList from '@/pages/requests/RequestList'
const MainRouter: FC = () => {
return (
<div className='p-4 overflow-hidden'>
<Header />
<SideBar />
<div className='flex-1 xl:ms-[269px] mt-[68px] xl:mt-[81px]'>
<div className={`overflow-auto w-[${window.innerWidth}] max-h-[calc(100vh-113px)]`}>
<div className='xl:pb-20 pb-32'>
<Routes>
<Route path="/" element={<Home />} />
<Route path={Paths.home} element={<Home />} />
<Route path={Paths.myOrders} element={<MyOrders />} />
<Route path={Paths.proformaInvoice} element={<ProformaInvoice />} />
<Route path={Paths.newOrder} element={<NewOrder />} />
<Route path={`${Paths.orderDetails}:id`} element={<OrderDetail />} />
<Route path={Paths.requests.list} element={<RequestList />} />
</Routes>
</div>
</div>
</div>
</div>
)
}
export default MainRouter
+172
View File
@@ -0,0 +1,172 @@
import { type FC, useEffect } from 'react'
import Input from '@/components/Input'
import { HambergerMenu, Wallet } from 'iconsax-react'
// import AvatarImage from '@/assets/images/avatar_image.png'
import { useLocation } from 'react-router-dom'
import Notifications from '@/pages/notification/Notification'
import { useSharedStore } from './store/useSharedStore'
// import { useGetProfile } from '@/pages/profile/hooks/useProfileData'
// import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
// import SidebarItem from './SidebarItem'
// import { useGetWalletBalance } from '@/pages/wallet/hooks/useWalletData'
// import { NumberFormat } from '../config/func'
import { t } from '@/locale'
const Header: FC = () => {
const location = useLocation();
// const [popoverKey, setPopoverKey] = useState(0);
const { setOpenSidebar, openSidebar } = useSharedStore()
// const { data } = useGetProfile()
// const getWalletBalance = useGetWalletBalance()
useEffect(() => {
// setPopoverKey((prevKey) => prevKey + 1);
}, [location.pathname]);
return (
<div className='fixed z-10 right-4 left-4 xl:right-[285px] top-4 xl:h-16 h-12 flex items-center px-6 bg-white justify-between rounded-[32px] xl:w-[calc(100%-305px)]'>
<div className='min-w-[270px] hidden xl:block'>
<Input
variant='search'
placeholder={t('header.search')}
/>
</div>
<div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'>
<HambergerMenu size={24} color='black' />
</div>
{/* <img src={LogoImage} className='h-6 xl:hidden block absolute right-0 left-0 mx-auto' /> */}
<div className='flex xl:gap-6 gap-4 items-center'>
{/* <Link to={Pages.services.other}>
<Element3 color='black' className='xl:size-[18px] size-[17px]' />
</Link>
<Link className='xl:hidden' to={Pages.wallet}>
<Wallet className='xl:size-[18px] size-[17px]' color='black' />
</Link>
<Link className='hidden xl:block' to={Pages.wallet}> */}
<div className='flex items-center h-8 pl-2 rounded-full bg-[#EEF0F7]'>
<div className='px-3 text-xs'>
{/* {NumberFormat(getWalletBalance.data?.data?.balance) + ' ' + t('toman')} */}
20,132
</div>
<div className='size-[26px] flex justify-center items-center bg-white rounded-xl'>
<Wallet className='xl:size-[18px] size-[17px]' color='black' />
</div>
</div>
{/* </Link> */}
<Notifications />
{/* {
data && (
<Popover className="relative" key={popoverKey}>
<PopoverButton >
<div className='flex gap-2 items-center mt-2.5'>
<div className='size-6 rounded-full bg-description overflow-hidden'>
<img src={data?.data?.user?.profilePic ? data?.data?.user?.profilePic : AvatarImage} className='size-full object-cover' />
</div>
<div className='xl:flex hidden gap-1 items-center'>
<div className='text-xs'>
{data?.data?.user?.firstName + ' ' + data?.data?.user?.lastName}
</div>
<ArrowDown2 size={14} color='#8C90A3' />
</div>
</div>
</PopoverButton>
<PopoverPanel style={{ minHeight: window.innerWidth < 1140 ? window.innerHeight : undefined }} anchor="bottom" className="flex xl:ml-6 overflow-auto flex-col gap-3 bg-white boxShadow xl:mt-7 z-30 py-4 text-xs rounded-2.5 xl:w-[300px] -mt-[50px] w-full fixed xl:h-fit h-full shadow-md">
<div className='absolute xl:hidden top-6 left-6'>
<CloseCircle onClick={() => setPopoverKey((prevKey) => prevKey + 1)} size={20} color='black' />
</div>
<Link to={Pages.profile} className='flex flex-col gap-2 items-center justify-center border-b border-[#EAEDF5] pb-4'>
<div className='size-14 rounded-full overflow-hidden'>
<img src={data?.data?.user?.profilePic ? data?.data?.user?.profilePic : AvatarImage} className='size-full object-cover' />
</div>
<div>
{data?.data?.user?.firstName + ' ' + data?.data?.user?.lastName}
</div>
<div className='text-description'>
{data?.data?.user?.email}
</div>
</Link>
<div className='pb-6 px-6 border-b border-[#EAEDF5]'>
<div className='flex justify-between items-center'>
<SidebarItem
icon={<Wallet size={20} color='black' />}
title={t('header.wallet')}
link={Pages.wallet}
isActive
isWithoutLine
/>
<div className='flex xl:hidden items-center mt-4 h-8 pl-2 rounded-full bg-[#EEF0F7]'>
<div className='ps-3'>{t('home.balance')}</div>
<div className='px-3 text-xs'>
{NumberFormat(getWalletBalance.data?.data?.balance) + ' ' + t('toman')}
</div>
</div>
</div>
<SidebarItem
icon={<Card color={'black'} size={20} />}
title={t('sidebar.transactions')}
isActive
link={Pages.transactions}
isWithoutLine
/>
<SidebarItem
icon={<TicketDiscount color={'black'} size={20} />}
title={t('header.discounts')}
isActive
link={Pages.discounts}
isWithoutLine
/>
<SidebarItem
icon={<Receipt1 color={'black'} size={20} />}
title={t('header.financial_info')}
isActive
link={Pages.financial}
isWithoutLine
/>
<SidebarItem
icon={<ProfileCircle color={'black'} size={20} />}
title={t('header.profile')}
isActive
link={Pages.profile}
isWithoutLine
/>
<SidebarItem
icon={<Setting2 color={'black'} size={20} />}
title={t('header.setting')}
isActive
link={Pages.setting}
isWithoutLine
/>
</div>
<div className='px-6 -mt-[2px]'>
<SidebarItem
icon={<Logout color={'black'} size={20} />}
title={t('sidebar.logout')}
isActive
link={Pages.setting}
isWithoutLine
isLogout
/>
</div>
</PopoverPanel>
</Popover>
)
} */}
</div>
</div>
)
}
export default Header
+135
View File
@@ -0,0 +1,135 @@
import { type FC } from 'react'
import LogoImage from '@/assets/images/logo.svg'
import { AddSquare, DocumentText, Element3, Home2, Messages3, NotificationStatus, Receipt21, Teacher } from 'iconsax-react'
import SideBarItem from './SidebarItem'
import { useLocation } from 'react-router-dom'
import { Paths } from '../config/Paths'
import { useSharedStore } from './store/useSharedStore'
import { clx } from '../helpers/utils'
import { t } from '../locale'
import Button from '@/components/Button'
const SideBar: FC = () => {
const { openSidebar, setOpenSidebar } = useSharedStore()
const location = useLocation()
const isActive = (name: string) => {
return location.pathname.includes(name)
}
const iconSizeSideBar = 20
return (
<>
{
openSidebar && <div className='fixed top-0 left-0 right-0 bottom-0 bg-black bg-opacity-50 z-10' onClick={() => setOpenSidebar(false)} />
}
<div
className={clx(
'fixed xl:flex translate-x-[400px] xl:translate-x-0 transition-all ease-in-out opacity-0 invisible xl:visible xl:opacity-100 xl:right-4 right-0 xl:top-4 top-0 xl:bottom-4 bottom-0 xl:rounded-[32px] w-[240px] bg-white flex-col py-12',
openSidebar && 'opacity-100 visible -translate-x-[0px] block z-40'
)}
>
<div className='px-6'>
<div className='flex border-b-2 border-border pb-10'>
<img src={LogoImage} className='w-[120px]' />
</div>
</div>
<div className='flex-1 h-full overflow-y-auto no-scrollbar px-6'>
<div className='mt-10 text-description px-6 text-header text-sm font-bold'>
{t('sidebar.menu')}
</div>
<div className='mt-3'>
<SideBarItem
icon={<Home2 size={iconSizeSideBar} color='#4F5260' />}
title={t('sidebar.mainPage')}
isActive={isActive('/home')}
link={Paths.home}
/>
<SideBarItem
icon={<Element3 size={iconSizeSideBar} color='#4F5260' />}
title={t('sidebar.myOrders')}
isActive={isActive('/my-orders')}
link={Paths.myOrders}
/>
<SideBarItem
icon={<Receipt21 size={iconSizeSideBar} color='#4F5260' />}
title={t('sidebar.preFactors')}
isActive={isActive('/proforma-invoice')}
link={Paths.proformaInvoice}
/>
</div>
<div className='mt-10 px-6 text-description text-sm font-bold'>
{t('sidebar.other')}
</div>
<div className='mt-3'>
<SideBarItem
icon={<Messages3 size={iconSizeSideBar} color='#4F5260' />}
title={t('sidebar.tickets')}
isActive={isActive('/tickets')}
link={Paths.tickets.list}
/>
<SideBarItem
icon={<NotificationStatus size={iconSizeSideBar} color='#4F5260' />}
title={t('sidebar.announcement')}
isActive={isActive('/announcement')}
link={Paths.announcement.list}
/>
<SideBarItem
icon={<DocumentText size={iconSizeSideBar} color='#4F5260' />}
title={t('sidebar.criticisms')}
isActive={isActive('/criticisms')}
link={Paths.criticisms}
/>
<SideBarItem
icon={<Teacher size={iconSizeSideBar} color='#4F5260' />}
title={t('sidebar.learning')}
isActive={isActive('/learning')}
link={Paths.learning}
/>
</div>
<div className='flex-1 items-end mt-14 pb-8'>
<div className='bg-[#F5F7FC] rounded-2xl p-4 text-center'>
<div className='text-[15px] font-medium'>{t('sidebar.submitYourOrder')}</div>
<div className='mt-2.5 text-[13px] text-[#7B7E8B]'>{t('sidebar.submitYourOrderDescription')}</div>
<Button
className='flex items-center mt-2.5'
>
<div className='flex gap-1'>
<AddSquare size={iconSizeSideBar} color='black' />
<div>{t('sidebar.newOrder')}</div>
</div>
</Button>
</div>
{/* <div className='text-xs text-[#8C90A3]'>
<SideBarItem
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('sidebar.logout')}
isActive={isActive('logout')}
link={`#`}
isLogout
/>
</div> */}
</div>
</div>
</div>
</>
)
}
export default SideBar
+45
View File
@@ -0,0 +1,45 @@
import type { FC, ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { clx } from '../helpers/utils'
import { Paths } from '../config/Paths'
// import { useLogout } from '../pages/auth/hooks/useAuthData'
import { removeToken } from '../config/func'
import { removeRefreshToken } from '../config/func'
type Props = {
icon: ReactNode,
title: string,
isActive: boolean,
link: string,
isLogout?: boolean,
isWithoutLine?: boolean
}
const SideBarItem: FC<Props> = (props: Props) => {
// const logout = useLogout()
const handleLogout = async () => {
// await logout.mutateAsync()
removeToken()
removeRefreshToken()
window.location.href = Paths.auth.login
}
return (
<Link onClick={props.isLogout ? handleLogout : undefined} to={props.link} className={clx(
'h-12 rounded-2xl flex gap-2 text-[#4F5260] px-4 text-[13px] items-center',
props.isActive && 'bg-[#F5F7FC]',
)}>
<div className='flex gap-3 items-center'>
{props.icon}
<div className={`text-[#4F5260]`}>
{props.title}
</div>
</div>
</Link>
)
}
export default SideBarItem
+15
View File
@@ -0,0 +1,15 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { SharedStoreType } from "../types/Types";
export const useSharedStore = create<SharedStoreType>()(
persist(
(set): SharedStoreType => ({
openSidebar: false,
setOpenSidebar: (open) => set({ openSidebar: open }),
}),
{
name: "negareh-share-store",
}
)
);
+21
View File
@@ -0,0 +1,21 @@
interface Toast {
id: string;
message: string;
type?: "success" | "error" | "info";
isExiting?: boolean;
}
let addToast: (toast: Toast) => void;
export const toast = (
message?: string,
type: "success" | "error" | "info" = "info"
) => {
if (addToast) {
addToast({ id: Date.now().toString(), message: message || "", type });
}
};
export const setAddToast = (fn: (toast: Toast) => void) => {
addToast = fn;
};
+4
View File
@@ -0,0 +1,4 @@
export type SharedStoreType = {
openSidebar: boolean;
setOpenSidebar: (open: boolean) => void;
};
+40
View File
@@ -0,0 +1,40 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": [
"vite/client"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": [
"ES2023"
],
"module": "ESNext",
"types": [
"node"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"vite.config.ts"
]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler"]],
},
}),
tailwindcss(),
tsconfigPaths(),
],
});