copy dmenu-admin to dkala-admin

This commit is contained in:
hamid zarghami
2026-02-07 16:20:18 +03:30
commit 6b9e808bfd
292 changed files with 27167 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
VITE_TOKEN_NAME = 'dmnu_a_t'
VITE_REFRESH_TOKEN_NAME = 'dmnu-a-rt'
VITE_BASE_URL = 'https://dmenuplus-api-production.dev.danakcorp.com'
# VITE_BASE_URL = 'http://192.168.99.242:4000'
VITE_SOCKET_URL = 'https://dmenuplus-api-production.dev.danakcorp.com'
+24
View File
@@ -0,0 +1,24 @@
# 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?
+25
View File
@@ -0,0 +1,25 @@
FROM node:20-alpine AS builder
# Install tzdata to support timezone settings
RUN apk add --no-cache tzdata
# Set the timezone to Asia/Tehran
RUN cp /usr/share/zoneinfo/Asia/Tehran /etc/localtime && echo "Asia/Tehran" > /etc/timezone
WORKDIR /build
COPY package*.json ./
RUN npm install
COPY . ./
RUN npm run build
FROM nginx:stable-alpine AS production-stage
COPY --from=builder /build/dist /usr/share/nginx/html
COPY --from=builder /build/nginx.con[f] /etc/nginx/conf.d/default.conf
RUN cat /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+69
View File
@@ -0,0 +1,69 @@
# 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/) 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
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
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 tseslint.config([
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...
},
},
])
```
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"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 { globalIgnores } from 'eslint/config'
export default tseslint.config([
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,
},
},
])
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>پنل مدیریت دی منو</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload" always;
# add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location /static/ {
expires 1y;
add_header Cache-Control "public, no-transform";
}
location / {
try_files $uri $uri/ /index.html;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# location /health {
# access_log off;
# return 200;
# }
location ~ /\. {
deny all;
return 403;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
internal;
}
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
gzip_disable "MSIE [1-6]\.";
}
+6008
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
{
"name": "dmenu-admin",
"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.7",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-icons": "^1.3.2",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"formik": "^2.4.6",
"i18next": "^25.4.2",
"iconsax-react": "^0.0.8",
"leaflet": "^1.9.4",
"moment-jalaali": "^0.10.4",
"rc-rate": "^2.13.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-dropzone": "^14.3.8",
"react-i18next": "^15.7.2",
"react-infinite-scroll-component": "^6.1.1",
"react-leaflet": "^5.0.0-rc.2",
"react-loading-skeleton": "^3.5.0",
"react-multi-date-picker": "^4.5.2",
"react-otp-input": "^3.1.1",
"react-router-dom": "^7.8.2",
"react-spinners": "^0.17.0",
"react-toastify": "^11.0.5",
"recharts": "^3.6.0",
"socket.io-client": "^4.8.1",
"swiper": "^11.2.10",
"tailwind-merge": "^3.3.1",
"yup": "^1.7.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/leaflet": "^1.9.21",
"@types/moment-jalaali": "^0.7.9",
"@types/node": "^24.10.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.12",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Disallow: /
+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

+201
View File
@@ -0,0 +1,201 @@
import { type FC, useEffect, useState } from 'react'
import { BrowserRouter } from 'react-router-dom'
import 'swiper/swiper-bundle.css';
// import 'rc-rate/assets/index.css';
import "react-multi-date-picker/styles/layouts/mobile.css"
import 'leaflet/dist/leaflet.css';
import './assets/fonts/irancell/style.css'
import i18next from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ToastContainer } from 'react-toastify'
import FaJson from './langs/fa.json'
import { type IApiErrorRepsonse } from './types/error.types'
import MainRouter from './router/Main'
import AuthRouter from './router/Auth'
import { Pages } from './config/Pages'
import { getRefreshToken, setToken, setRefreshToken, removeToken, removeRefreshToken, getToken } from './config/func';
import { refreshToken } from './pages/auth/service/AuthService';
i18next.init({
interpolation: { escapeValue: false },
lng: 'fa',
resources: {
fa: {
global: FaJson
}
}
})
declare global {
interface Window {
isRefreshingToken?: boolean;
isRefreshTokenExpired?: boolean;
}
}
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: async (error: IApiErrorRepsonse) => {
if (error?.response?.status === 401) {
// اگر refresh token منقضی شده باشد، دیگر تلاش نکن
if (window.isRefreshTokenExpired) {
return;
}
// بررسی کن که آیا خطا از refresh endpoint است
const isRefreshEndpoint = error?.config?.url?.includes('/admin/auth/refresh');
// اگر خطا از refresh endpoint است، refresh token منقضی شده
if (isRefreshEndpoint) {
window.isRefreshTokenExpired = true;
await removeToken();
await removeRefreshToken();
window.location.href = Pages.auth.login;
return;
}
try {
// Use a flag to track if refresh is in progress
if (window.isRefreshingToken) {
// Wait for the refresh to complete
await new Promise(resolve => {
const checkComplete = setInterval(() => {
if (!window.isRefreshingToken) {
clearInterval(checkComplete);
resolve(true);
}
}, 100);
});
return;
}
// Set the flag to indicate refresh is in progress
window.isRefreshingToken = true;
try {
const refreshTokenValue = await getRefreshToken();
if (!refreshTokenValue) {
window.isRefreshTokenExpired = true;
await removeToken();
await removeRefreshToken();
window.location.href = Pages.auth.login;
return;
}
const { data } = await refreshToken({ refreshToken: refreshTokenValue || '' });
if (data?.accessToken?.token) {
// Save the new token
await setToken(data?.accessToken?.token);
// If refresh token also returned, update it
if (data.refreshToken?.token) {
await setRefreshToken(data.refreshToken?.token);
}
// Reset expired flag if refresh was successful
window.isRefreshTokenExpired = false;
// Retry the failed request
queryClient.invalidateQueries();
return;
} else {
throw new Error('Invalid token response');
}
} finally {
window.isRefreshingToken = false;
}
} catch (refreshError: unknown) {
console.error('Token refresh failed:', refreshError);
// بررسی کن که آیا خطا از refresh endpoint است
const errorWithConfig = refreshError as IApiErrorRepsonse;
const isRefreshError = errorWithConfig?.config?.url?.includes('/admin/auth/refresh');
// اگر refresh token خودش 401 خورده، آن را منقضی شده علامت بزن
if (isRefreshError || errorWithConfig?.response?.status === 401) {
window.isRefreshTokenExpired = true;
}
// Clear tokens and redirect to login
await removeToken();
await removeRefreshToken();
window.location.href = Pages.auth.login;
}
}
}
}),
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false
// staleTime: 86400000 // 1 day in milliseconds
}
}
});
const App: FC = () => {
const [isLogin, setIsLogin] = useState<'checking' | 'isLogin' | 'isNotLogin'>('checking')
useEffect(() => {
// استخراج توکن‌ها از URL در صورت وجود
const urlParams = new URLSearchParams(window.location.search)
const tokenFromUrl = urlParams.get('token')
const refreshTokenFromUrl = urlParams.get('refreshToken')
if (tokenFromUrl && refreshTokenFromUrl) {
// ذخیره توکن‌ها (جایگزین کردن توکن‌های قبلی اگر وجود داشته باشند)
setToken(tokenFromUrl)
setRefreshToken(refreshTokenFromUrl)
// حذف پارامترها از URL
const newUrl = window.location.pathname
window.history.replaceState({}, '', newUrl)
// تنظیم وضعیت لاگین و هدایت به صفحه اصلی
setIsLogin('isLogin')
if (window.location.pathname === Pages.auth.login) {
window.location.href = Pages.dashboard
}
return
}
const token = getToken()
if (token) {
setIsLogin('isLogin')
} else {
setIsLogin('isNotLogin')
if (window.location.href.split('auth').length === 1) {
window.location.href = Pages.auth.login
}
}
}, [])
return (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18next}>
{
isLogin === 'checking' ?
<div>loading</div>
:
isLogin === 'isLogin' ?
<MainRouter />
:
<AuthRouter />
}
<ToastContainer />
</I18nextProvider>
</QueryClientProvider>
</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: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.97508 5.44165L2.91675 10.5L7.97508 15.5583" stroke="#292D32" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.0834 10.5H3.05835" stroke="#292D32" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.16675 1.66675V4.16675" stroke="#292D32" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.8333 1.66675V4.16675" stroke="#292D32" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.41675 7.57495H17.5834" stroke="#292D32" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 7.08341V14.1667C18 16.6667 16.75 18.3334 13.8333 18.3334H7.16667C4.25 18.3334 3 16.6667 3 14.1667V7.08341C3 4.58341 4.25 2.91675 7.16667 2.91675H13.8333C16.75 2.91675 18 4.58341 18 7.08341Z" stroke="#292D32" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5788 11.4167H13.5863" stroke="#292D32" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5788 13.9167H13.5863" stroke="#292D32" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.4963 11.4167H10.5038" stroke="#292D32" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.4963 13.9167H10.5038" stroke="#292D32" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.41185 11.4167H7.41933" stroke="#292D32" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.41185 13.9167H7.41933" stroke="#292D32" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 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="M12.9833 9.99993C12.9833 11.6499 11.6499 12.9833 9.99993 12.9833C8.34993 12.9833 7.0166 11.6499 7.0166 9.99993C7.0166 8.34993 8.34993 7.0166 9.99993 7.0166C11.6499 7.0166 12.9833 8.34993 12.9833 9.99993Z" stroke="#292D32" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.99987 16.8918C12.9415 16.8918 15.6832 15.1584 17.5915 12.1584C18.3415 10.9834 18.3415 9.00843 17.5915 7.83343C15.6832 4.83343 12.9415 3.1001 9.99987 3.1001C7.0582 3.1001 4.31654 4.83343 2.4082 7.83343C1.6582 9.00843 1.6582 10.9834 2.4082 12.1584C4.31654 15.1584 7.0582 16.8918 9.99987 16.8918Z" stroke="#292D32" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

+45
View File
@@ -0,0 +1,45 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 201.8 46.7" xml:space="preserve" class="logo-shape dark" style="enable-background:new 0 0 201.8 46.7;"><g><g><path d="M51,38.6h-1.9v5.1h-0.8v-5.1h-1.9V38H51L51,38.6L51,38.6z" class="st0"></path> <path d="M57.6,43.8c-0.6,0-1.1-0.2-1.4-0.6c-0.3-0.4-0.5-0.9-0.5-1.5v-0.2c0-0.6,0.2-1.1,0.5-1.5s0.8-0.6,1.3-0.6
c0.6,0,1,0.2,1.3,0.5s0.4,0.8,0.4,1.4v0.4h-2.7l0,0c0,0.4,0.1,0.8,0.3,1c0.2,0.3,0.5,0.4,0.9,0.4c0.3,0,0.5,0,0.7-0.1
c0.2-0.1,0.4-0.2,0.5-0.3l0.3,0.5c-0.1,0.1-0.4,0.3-0.6,0.4C58.3,43.7,58,43.8,57.6,43.8z M57.5,40c-0.3,0-0.5,0.1-0.7,0.3
c-0.2,0.2-0.3,0.5-0.3,0.8l0,0h1.9V41c0-0.3-0.1-0.6-0.2-0.8S57.8,40,57.5,40z" class="st0"></path> <path d="M66.3,43.2c0.2,0,0.5-0.1,0.6-0.2s0.3-0.4,0.3-0.6h0.7l0,0c0,0.4-0.1,0.7-0.5,1c-0.3,0.3-0.7,0.4-1.1,0.4
c-0.6,0-1.1-0.2-1.4-0.6c-0.3-0.4-0.5-0.9-0.5-1.5v-0.2c0-0.6,0.2-1.1,0.5-1.5s0.8-0.6,1.4-0.6c0.5,0,0.9,0.1,1.2,0.4
c0.3,0.3,0.5,0.7,0.4,1.1l0,0h-0.7c0-0.3-0.1-0.5-0.3-0.7S66.5,40,66.3,40c-0.4,0-0.7,0.1-0.8,0.4c-0.2,0.3-0.3,0.6-0.3,1.1v0.2
c0,0.4,0.1,0.8,0.3,1.1C65.6,43,65.9,43.2,66.3,43.2z" class="st0"></path> <path d="M74,40c0.1-0.2,0.3-0.4,0.5-0.5s0.5-0.2,0.7-0.2c0.5,0,0.8,0.1,1.1,0.4s0.4,0.7,0.4,1.3v2.7H76V41
c0-0.4-0.1-0.6-0.2-0.8C75.6,40.1,75.3,40,75,40c-0.2,0-0.4,0-0.6,0.1c-0.2,0.1-0.3,0.2-0.4,0.4v3.2h-0.8v-6.1H74L74,40L74,40z" class="st0"></path> <path d="M83,39.4l0.1,0.6c0.1-0.2,0.3-0.4,0.5-0.5c0.2-0.1,0.5-0.2,0.7-0.2c0.5,0,0.8,0.1,1.1,0.4s0.4,0.7,0.4,1.3
v2.7H85V41c0-0.4-0.1-0.6-0.2-0.8c-0.1-0.2-0.4-0.2-0.7-0.2c-0.2,0-0.4,0-0.6,0.1c-0.2,0.1-0.3,0.2-0.4,0.4v3.2h-0.8v-4.3L83,39.4
L83,39.4z" class="st0"></path> <path d="M91.2,41.5c0-0.6,0.2-1.1,0.5-1.6s0.8-0.6,1.4-0.6c0.6,0,1.1,0.2,1.4,0.6s0.5,0.9,0.5,1.6v0.1
c0,0.6-0.2,1.2-0.5,1.6s-0.8,0.6-1.4,0.6c-0.6,0-1-0.2-1.4-0.6C91.4,42.8,91.2,42.3,91.2,41.5L91.2,41.5z M92,41.6
c0,0.5,0.1,0.8,0.3,1.1s0.5,0.4,0.9,0.4c0.4,0,0.6-0.1,0.8-0.4s0.3-0.7,0.3-1.1v-0.1c0-0.5-0.1-0.8-0.3-1.1
c-0.2-0.3-0.5-0.5-0.9-0.5s-0.6,0.1-0.8,0.5S92,41.1,92,41.6L92,41.6z" class="st0"></path> <path d="M101.3,43.7h-0.8v-6.1h0.8V43.7z" class="st0"></path> <path d="M106.8,41.5c0-0.6,0.2-1.1,0.5-1.6s0.8-0.6,1.4-0.6c0.6,0,1.1,0.2,1.4,0.6s0.5,0.9,0.5,1.6v0.1
c0,0.6-0.2,1.2-0.5,1.6s-0.8,0.6-1.4,0.6s-1-0.2-1.4-0.6C107,42.8,106.8,42.3,106.8,41.5L106.8,41.5z M107.6,41.6
c0,0.5,0.1,0.8,0.3,1.1s0.5,0.4,0.9,0.4c0.4,0,0.6-0.1,0.8-0.4s0.3-0.7,0.3-1.1v-0.1c0-0.5-0.1-0.8-0.3-1.1s-0.5-0.5-0.9-0.5
s-0.6,0.1-0.8,0.5S107.6,41.1,107.6,41.6L107.6,41.6z" class="st0"></path> <path d="M115.9,41.7c0-0.7,0.1-1.2,0.4-1.7s0.7-0.6,1.2-0.6c0.3,0,0.5,0,0.7,0.2c0.2,0.1,0.4,0.3,0.5,0.5l0.1-0.6h0.6
v4.3c0,0.5-0.2,1-0.5,1.3s-0.8,0.4-1.4,0.4c-0.2,0-0.4,0-0.7-0.1c-0.2-0.1-0.5-0.1-0.6-0.2l0.1-0.6c0.1,0.1,0.3,0.1,0.5,0.2
c0.2,0,0.4,0.1,0.6,0.1c0.4,0,0.6-0.1,0.8-0.3c0.2-0.2,0.3-0.4,0.3-0.8v-0.5c-0.1,0.2-0.3,0.3-0.5,0.4c-0.2,0.1-0.4,0.1-0.7,0.1
c-0.5,0-0.9-0.2-1.2-0.6S115.9,42.4,115.9,41.7L115.9,41.7L115.9,41.7z M116.7,41.7c0,0.4,0.1,0.8,0.3,1s0.5,0.4,0.8,0.4
c0.2,0,0.4,0,0.6-0.2c0.2-0.1,0.3-0.3,0.4-0.5v-2c-0.1-0.2-0.2-0.3-0.4-0.4c-0.2-0.1-0.4-0.2-0.6-0.2c-0.4,0-0.6,0.1-0.8,0.5
S116.7,41.2,116.7,41.7L116.7,41.7L116.7,41.7z" class="st0"></path> <path d="M126.4,42.1l0.1,0.5h0l1-3.2h0.9l-1.8,4.9c-0.1,0.3-0.3,0.5-0.5,0.8c-0.2,0.2-0.5,0.3-0.8,0.3
c-0.1,0-0.1,0-0.2,0c-0.1,0-0.2,0-0.2,0l0.1-0.6c0,0,0,0,0.1,0c0.1,0,0.2,0,0.2,0c0.2,0,0.3-0.1,0.4-0.2c0.1-0.1,0.2-0.3,0.3-0.5
l0.2-0.5l-1.6-4.2h0.9L126.4,42.1z" class="st0"></path> <path d="M144.4,41.9L144.4,41.9c0,0.6-0.2,1-0.6,1.4s-0.9,0.5-1.5,0.5c-0.6,0-1.2-0.2-1.6-0.7s-0.6-1-0.6-1.8v-1
c0-0.7,0.2-1.3,0.6-1.8s0.9-0.7,1.6-0.7s1.2,0.2,1.5,0.5c0.4,0.3,0.6,0.8,0.6,1.4l0,0h-0.8c0-0.4-0.1-0.7-0.4-1
c-0.2-0.2-0.6-0.4-1-0.4s-0.8,0.2-1,0.5c-0.3,0.4-0.4,0.8-0.4,1.3v1c0,0.5,0.1,1,0.4,1.3s0.6,0.5,1,0.5c0.4,0,0.8-0.1,1-0.4
s0.4-0.5,0.4-1L144.4,41.9L144.4,41.9z" class="st0"></path> <path d="M149.6,41.5c0-0.6,0.2-1.1,0.5-1.6s0.8-0.6,1.4-0.6c0.6,0,1.1,0.2,1.4,0.6s0.5,0.9,0.5,1.6v0.1
c0,0.6-0.2,1.2-0.5,1.6s-0.8,0.6-1.4,0.6s-1-0.2-1.4-0.6C149.8,42.8,149.6,42.3,149.6,41.5L149.6,41.5z M150.4,41.6
c0,0.5,0.1,0.8,0.3,1.1s0.5,0.4,0.9,0.4c0.4,0,0.6-0.1,0.8-0.4s0.3-0.7,0.3-1.1v-0.1c0-0.5-0.1-0.8-0.3-1.1s-0.5-0.5-0.9-0.5
s-0.6,0.1-0.8,0.5S150.4,41.1,150.4,41.6L150.4,41.6z" class="st0"></path> <path d="M159.5,39.4l0,0.6c0.1-0.2,0.3-0.4,0.5-0.5c0.2-0.1,0.5-0.2,0.8-0.2s0.5,0.1,0.7,0.2s0.4,0.3,0.5,0.6
c0.1-0.3,0.3-0.4,0.5-0.6c0.2-0.1,0.5-0.2,0.8-0.2c0.4,0,0.8,0.1,1,0.5c0.3,0.3,0.4,0.8,0.4,1.3v2.5H164v-2.6
c0-0.4-0.1-0.7-0.2-0.9c-0.1-0.2-0.4-0.3-0.6-0.3c-0.3,0-0.5,0.1-0.6,0.3c-0.2,0.2-0.3,0.4-0.3,0.7v0v2.7h-0.8v-2.6
c0-0.4-0.1-0.7-0.2-0.9s-0.4-0.3-0.6-0.3c-0.2,0-0.4,0-0.6,0.1s-0.3,0.2-0.4,0.4v3.2h-0.8v-4.3L159.5,39.4L159.5,39.4z" class="st0"></path> <path d="M174,41.7c0,0.6-0.1,1.1-0.4,1.5s-0.7,0.6-1.2,0.6c-0.3,0-0.5,0-0.7-0.1s-0.4-0.2-0.5-0.4v2.1h-0.8v-5.9h0.6
l0.1,0.5c0.1-0.2,0.3-0.4,0.5-0.5c0.2-0.1,0.5-0.2,0.7-0.2c0.5,0,0.9,0.2,1.2,0.6S174,41,174,41.7L174,41.7L174,41.7z M173.2,41.7
c0-0.5-0.1-0.9-0.3-1.2s-0.5-0.5-0.8-0.5c-0.2,0-0.4,0-0.6,0.1s-0.3,0.2-0.4,0.4v2.1c0.1,0.2,0.2,0.3,0.4,0.4s0.4,0.1,0.6,0.1
c0.4,0,0.6-0.1,0.8-0.4C173.1,42.5,173.2,42.2,173.2,41.7L173.2,41.7L173.2,41.7z" class="st0"></path> <path d="M182.1,43.7c0-0.1,0-0.2-0.1-0.3s0-0.2,0-0.3c-0.1,0.2-0.3,0.4-0.6,0.5s-0.5,0.2-0.8,0.2
c-0.4,0-0.8-0.1-1-0.3s-0.4-0.5-0.4-0.9c0-0.4,0.2-0.7,0.5-1c0.3-0.2,0.8-0.3,1.3-0.3h0.8v-0.4c0-0.3-0.1-0.5-0.2-0.6
s-0.4-0.2-0.6-0.2c-0.3,0-0.5,0.1-0.6,0.2c-0.2,0.1-0.2,0.3-0.2,0.5l-0.7,0l0,0c0-0.3,0.1-0.6,0.4-0.9s0.7-0.4,1.2-0.4
c0.5,0,0.9,0.1,1.2,0.4c0.3,0.3,0.4,0.6,0.4,1.1v2c0,0.1,0,0.3,0,0.4c0,0.1,0,0.3,0.1,0.4L182.1,43.7L182.1,43.7z M180.8,43.1
c0.3,0,0.5-0.1,0.8-0.2c0.2-0.1,0.4-0.3,0.4-0.5v-0.7h-0.9c-0.3,0-0.6,0.1-0.8,0.2s-0.3,0.3-0.3,0.6c0,0.2,0.1,0.3,0.2,0.5
S180.5,43.1,180.8,43.1z" class="st0"></path> <path d="M189.1,39.4l0.1,0.6c0.1-0.2,0.3-0.4,0.5-0.5c0.2-0.1,0.5-0.2,0.7-0.2c0.5,0,0.8,0.1,1.1,0.4s0.4,0.7,0.4,1.3
v2.7H191V41c0-0.4-0.1-0.6-0.2-0.8c-0.1-0.2-0.4-0.2-0.7-0.2c-0.2,0-0.4,0-0.6,0.1c-0.2,0.1-0.3,0.2-0.4,0.4v3.2h-0.8v-4.3
L189.1,39.4L189.1,39.4z" class="st0"></path> <path d="M198.7,42.1l0.1,0.5h0l1-3.2h0.9l-1.8,4.9c-0.1,0.3-0.3,0.5-0.5,0.8s-0.5,0.3-0.8,0.3c-0.1,0-0.1,0-0.2,0
c-0.1,0-0.2,0-0.2,0l0.1-0.6c0,0,0,0,0.1,0s0.2,0,0.2,0c0.2,0,0.3-0.1,0.4-0.2s0.2-0.3,0.3-0.5l0.2-0.5l-1.6-4.2h0.9L198.7,42.1z" class="st0"></path></g></g> <g><path d="M46.4,31.9V9.9h6.2c1.9,0,3.6,0.4,5.1,1.3c1.5,0.9,2.6,2.1,3.4,3.6c0.8,1.6,1.2,3.4,1.2,5.4v1.4
c0,2.1-0.4,3.9-1.2,5.5c-0.8,1.6-2,2.8-3.4,3.6s-3.2,1.3-5.2,1.3H46.4z M49.3,12.2v17.3h3.1c2.2,0,4-0.7,5.2-2.1
c1.3-1.4,1.9-3.4,1.9-6v-1.3c0-2.5-0.6-4.5-1.8-5.9s-2.9-2.1-5-2.1L49.3,12.2L49.3,12.2z" class="st0"></path> <path d="M131.9,31.9H129l-11.1-17v17h-2.9V9.9h2.9L129,26.9V9.9h2.9L131.9,31.9L131.9,31.9z" class="st0"></path> <path d="M189.6,21.7l-2.7,2.8v7.5H184V9.8h2.9v10.9l9.8-10.9h3.5l-8.7,9.7l9.4,12.3h-3.5L189.6,21.7z" class="st0"></path> <polygon points="88.9,13.4 95.6,31.9 98.6,31.9 90.1,9.8 87.6,9.8 79.2,31.9 82.2,31.9 " class="st0"></polygon> <polygon points="157.9,13.4 164.7,31.9 167.6,31.9 159.2,9.8 156.7,9.8 148.2,31.9 151.2,31.9 " class="st0"></polygon></g> <path d="M27.5,30.2c0,0-5.5,5.3-10.1,9.8v6.1c-0.5,0.4,16.4-15.9,16.4-15.9L17.4,13v6.5L27.5,30.2z" class="st0"></path> <g><rect x="9.1" y="13.1" transform="matrix(0.7298 -0.6837 0.6837 0.7298 -14.5845 13.0725)" width="0.3" height="23.8" class="st0"></rect> <rect x="9.9" y="13.4" transform="matrix(0.7287 -0.6849 0.6849 0.7287 -13.8472 13.4064)" width="0.3" height="21.6" class="st0"></rect> <polygon points="17.3,30.5 4,16.4 4.2,16.2 17.5,30.3 " class="st0"></polygon> <polygon points="17.3,28.9 5.5,16.5 5.7,16.3 17.5,28.7 " class="st0"></polygon> <polygon points="17.2,27.2 7,16.5 7.2,16.3 17.5,27.1 " class="st0"></polygon></g> <g><polygon points="1.3,16.5 1.1,16.3 17.3,0.5 17.5,0.7 " class="st0"></polygon> <polygon points="2.8,16.5 2.6,16.3 17.3,2 17.4,2.2 " class="st0"></polygon> <polygon points="4.3,16.5 4.1,16.3 17.2,3.5 17.4,3.7 " class="st0"></polygon> <polygon points="5.8,16.5 5.6,16.3 17.2,5 17.4,5.2 " class="st0"></polygon> <polygon points="7.4,16.5 7.2,16.3 17.2,6.5 17.4,6.7 " class="st0"></polygon></g></svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="20" height="24" viewBox="0 0 20 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.647 0.479492C16.8265 0.521346 17.009 0.558249 17.188 0.605502C18.888 1.05554 20.0825 2.66307 19.9932 4.37501C19.8959 6.23726 18.5754 7.73093 16.7699 7.9816C15.5651 8.14901 14.5061 7.80158 13.6261 6.95236C13.4812 6.81285 13.3928 6.82095 13.2381 6.91771C11.3048 8.12591 9.36827 9.32931 7.42844 10.5279C7.30464 10.6044 7.27594 10.6688 7.32393 10.8096C7.59332 11.5806 7.59332 12.4207 7.32393 13.1917C7.27638 13.3325 7.30554 13.3964 7.42934 13.4729C9.38113 14.6793 11.3304 15.8905 13.2771 17.1065C13.4247 17.1988 13.4933 17.1682 13.6081 17.0615C14.7084 16.035 15.9931 15.706 17.4208 16.1835C18.8561 16.6637 19.7062 17.7073 19.9524 19.201C20.2866 21.2298 18.8239 23.1874 16.7946 23.4763C16.7439 23.484 16.6888 23.4763 16.6484 23.5214H15.841C15.4431 23.4282 15.0426 23.3499 14.6662 23.1748C13.8364 22.7926 13.1743 22.1195 12.8041 21.2821C12.4339 20.4447 12.3812 19.5004 12.6558 18.6268C12.7151 18.4386 12.6935 18.3491 12.5146 18.2388C10.5795 17.0486 8.64954 15.85 6.72467 14.643C6.5901 14.5593 6.52282 14.5665 6.40665 14.6759C5.32699 15.6948 4.05804 16.0377 2.64331 15.589C1.22859 15.1403 0.365577 14.1385 0.0704313 12.6813C-0.354794 10.5806 1.19764 8.48339 3.31928 8.25837C4.52498 8.13056 5.55844 8.49689 6.42414 9.33846C6.52596 9.43747 6.5901 9.44062 6.70673 9.36771C8.65911 8.14901 10.614 6.93391 12.5715 5.72242C12.6967 5.64456 12.72 5.582 12.6742 5.43754C11.9471 3.15496 13.3596 0.890825 15.7356 0.515945C15.7713 0.506836 15.8061 0.494639 15.8397 0.479492L16.647 0.479492ZM16.2321 22.1411C16.7034 22.1421 17.1645 22.0029 17.557 21.7412C17.9496 21.4795 18.256 21.1069 18.4377 20.6706C18.6193 20.2342 18.6681 19.7537 18.5777 19.2896C18.4874 18.8255 18.262 18.3986 17.9301 18.0629C17.5981 17.7273 17.1745 17.4978 16.7126 17.4035C16.2508 17.3092 15.7714 17.3543 15.3351 17.5331C14.8988 17.7119 14.5251 18.0164 14.2611 18.4082C13.9972 18.8 13.8549 19.2614 13.8521 19.7343C13.8501 20.0493 13.9102 20.3617 14.0288 20.6533C14.1475 20.945 14.3224 21.2103 14.5436 21.434C14.7647 21.6576 15.0278 21.8352 15.3175 21.9566C15.6073 22.0779 15.9181 22.1406 16.2321 22.1411ZM16.2299 1.8593C15.7587 1.86064 15.2984 2.002 14.9072 2.26554C14.516 2.52908 14.2113 2.90299 14.0317 3.34008C13.8521 3.77716 13.8056 4.25784 13.898 4.72144C13.9904 5.18504 14.2176 5.61078 14.551 5.94493C14.8844 6.27908 15.3089 6.50666 15.7711 6.59895C16.2332 6.69124 16.7123 6.6441 17.1477 6.46348C17.5832 6.28287 17.9556 5.97687 18.2179 5.58411C18.4802 5.19135 18.6207 4.72943 18.6216 4.25665C18.6232 3.94118 18.5624 3.62853 18.4427 3.3368C18.3231 3.04506 18.1469 2.78004 17.9245 2.55708C17.7021 2.33411 17.4377 2.15763 17.1469 2.03786C16.856 1.91808 16.5443 1.85739 16.2299 1.8593ZM3.76783 9.60938C3.2971 9.60992 2.83707 9.75031 2.44578 10.0129C2.05448 10.2754 1.74944 10.6483 1.56914 11.0846C1.38884 11.5209 1.34136 12.0009 1.43267 12.4643C1.52399 12.9276 1.75002 13.3534 2.08225 13.688C2.41448 14.0226 2.83803 14.2509 3.29947 14.3443C3.76091 14.4376 4.23957 14.3918 4.67508 14.2125C5.11058 14.0333 5.48342 13.7286 5.74656 13.337C6.0097 12.9454 6.15136 12.4844 6.15366 12.0121C6.15598 11.6967 6.09591 11.384 5.97695 11.092C5.85798 10.8 5.68247 10.5346 5.46058 10.3111C5.23869 10.0877 4.97483 9.91059 4.68427 9.79013C4.3937 9.66968 4.08221 9.60824 3.76783 9.60938Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 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

+33
View File
@@ -0,0 +1,33 @@
import { type ButtonHTMLAttributes, type FC, memo, type ReactNode } 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,
colorLoading?: string,
} & 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 text-white w-full',
props.disabled && 'cursor-not-allowed opacity-60',
props.className
);
return (
<button disabled={props.isloading} {...props} className={`${buttonClass} ${props.className}`} >
{
props.isloading ?
<MoonLoader color={props.colorLoading || 'white'} size={16} />
:
props.label || props.children
}
</button>
)
})
export default Button
+102
View File
@@ -0,0 +1,102 @@
import { type FC, type InputHTMLAttributes, useState, useRef, useEffect } from 'react'
import { clx } from '../helpers/utils'
import Error from './Error'
import ColorfilterIcon from '@/assets/images/color.png'
type Props = {
label?: string
className?: string
error_text?: string
isNotRequired?: boolean
value?: string
onChange?: (value: string) => void
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'>
const ColorPicker: FC<Props> = (props: Props) => {
const [color, setColor] = useState<string>(props.value || '#000000')
const colorInputRef = useRef<HTMLInputElement>(null)
const inputClass = clx(
'w-full bg-white h-10 text-black block pl-10 pr-10 text-xs rounded-xl border border-border',
props.readOnly && 'bg-gray-100 border-0 text-description',
props.className
)
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newColor = event.target.value
setColor(newColor)
props.onChange?.(newColor)
}
const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
if (/^#[0-9A-Fa-f]{0,6}$/.test(value) || value === '') {
const finalColor = value || '#000000'
setColor(finalColor)
props.onChange?.(finalColor)
}
}
const handleColorIconClick = () => {
if (!props.readOnly) {
colorInputRef.current?.click()
}
}
useEffect(() => {
if (props.value !== undefined) {
setColor(props.value)
}
}, [props.value])
return (
<div className='w-full'>
<label className='text-sm'>
{props.label}
</label>
<div className='w-full relative mt-1'>
<input
type='text'
value={color}
onChange={handleTextChange}
placeholder='#000000'
readOnly={props.readOnly}
className={inputClass}
/>
<button
type='button'
onClick={handleColorIconClick}
disabled={props.readOnly}
className={clx(
'absolute top-0 bottom-0 my-auto left-3 cursor-pointer',
props.readOnly && 'cursor-not-allowed opacity-50'
)}
>
<img src={ColorfilterIcon} alt='color-filter' className='size-7' />
</button>
<div
className='absolute top-0 bottom-0 my-auto right-3 w-5 h-5 rounded-full'
style={{ backgroundColor: color }}
/>
<input
ref={colorInputRef}
type='color'
value={color}
onChange={handleColorChange}
className='absolute opacity-0 pointer-events-none w-0 h-0'
/>
{props.error_text && (
<Error errorText={props.error_text} />
)}
</div>
</div>
)
}
export default ColorPicker
+41
View File
@@ -0,0 +1,41 @@
import { EmptyWallet, Star1 } from 'iconsax-react'
import { type FC } from 'react'
interface CustomerStatsProps {
points?: number | string
walletBalance?: string
}
const CustomerStats: FC<CustomerStatsProps> = ({
points = '124',
walletBalance = '۲۴۰.۰۰۰ تومان'
}) => {
return (
<div className='bg-white rounded-4xl'>
<div className='flex gap-4 items-center'>
<div className='flex gap-1.5'>
<Star1 size={16} color='black' />
<div className='text-xs'>امتیاز</div>
</div>
<div className='flex-1 border-b border-dashed border-border'></div>
<div className='text-xs'>{points} امتیاز</div>
</div>
<div className='flex gap-4 mt-5 items-center'>
<div className='flex gap-1.5'>
<EmptyWallet size={16} color='black' />
<div className='text-xs'>موجودی کیف پول</div>
</div>
<div className='flex-1 border-b border-dashed border-border'></div>
<div className='text-xs'>{walletBalance}</div>
</div>
</div>
)
}
export default CustomerStats
+116
View File
@@ -0,0 +1,116 @@
import { useState, useEffect, useRef, 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 CalenderIcon from '../assets/images/calendar.svg'
import { clx } from '../helpers/utils';
type Props = {
onChange: (date: string) => void;
defaulValue?: string;
error_text?: string;
placeholder: string;
reset?: boolean;
isDateTime?: boolean;
className?: string;
label?: string
};
const DatePickerComponent: FC<Props> = (props: Props) => {
const [value, setValue] = useState<DateObject | null>(null);
const isSettingDefaultValue = useRef(false);
const isInitialized = useRef(false);
useEffect(() => {
if (props.reset) {
setValue(null);
}
}, [props.reset]);
useEffect(() => {
// فقط وقتی کاربر تاریخ رو تغییر داده onChange رو صدا بزن
if (isSettingDefaultValue.current) {
isSettingDefaultValue.current = false;
return;
}
// اگر هنوز initialize نشده و value null هست، onChange رو صدا نزن
if (!isInitialized.current && !value) {
return;
}
isInitialized.current = true;
if (value) {
// تبدیل تاریخ شمسی به میلادی
const gregorianDate = value.toDate();
const year = gregorianDate.getFullYear();
const month = String(gregorianDate.getMonth() + 1).padStart(2, '0');
const day = String(gregorianDate.getDate()).padStart(2, '0');
const formattedDate = `${year}-${month}-${day}`;
props.onChange(formattedDate);
} else {
props.onChange('');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(() => {
if (props.defaulValue) {
try {
const defaultDate = new DateObject({
date: props.defaulValue,
calendar: persian,
locale: persian_fa,
});
const currentValueStr = value ? `${value.year}-${value.month.number}-${value.day}` : null;
const defaultValueStr = `${defaultDate.year}-${defaultDate.month.number}-${defaultDate.day}`;
if (currentValueStr !== defaultValueStr) {
isSettingDefaultValue.current = true;
setValue(defaultDate);
}
} catch (error) {
console.error('Error parsing date:', error);
}
} else if (!props.defaulValue && value) {
isSettingDefaultValue.current = true;
setValue(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.defaulValue]);
return (
<div className="w-full">
{props.label &&
<div className='text-sm'>
{props.label}
</div>}
<div className={clx(
'relative ',
props.label && 'mt-1.5'
)}>
<DatePicker
placeholder={props.placeholder}
value={value}
onChange={(date) => setValue(date as DateObject)}
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-right"
className={`rmdp-mobile ${props.className}`}
/>
{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>
)}
<img src={CalenderIcon} className='absolute top-0 bottom-0 my-auto left-2' />
</div>
</div>
);
};
export default DatePickerComponent;
+62
View File
@@ -0,0 +1,62 @@
import { type FC, Fragment, type ReactNode, useEffect } from 'react'
import { createPortal } from 'react-dom'
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])
const modalContent = props.open ? (
<Fragment>
<div
onClick={props.close}
className='fixed size-full top-0 bottom-0 right-0 left-0 bg-black/40 inset-0 z-[9998]'
/>
<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-[9999] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto pointer-events-none'
>
<div
className='relative xl:h-full h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2 pointer-events-auto'
onClick={(e) => e.stopPropagation()}
>
<div className='border-0 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 bg-white outline-none focus:outline-none shadow-2xl'>
{
props.isHeader && props.title_header &&
<div className='pb-3 border-b border-border'>
<div className='h-[5px] w-[200px] mx-auto bg-[#D1D3D7] rounded-full mb-4 xl:hidden'></div>
<HeaderModal close={props.close} label={props.title_header} />
</div>
}
{props.children}
</div>
</div>
</div>
</Fragment>
) : null
return createPortal(modalContent, document.body)
}
export default DefaulModal
+29
View File
@@ -0,0 +1,29 @@
import { type FC, Fragment } from 'react'
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 key={colIndex} className="px-3 md:px-6 py-3 md:py-4 whitespace-nowrap text-xs">
<Skeleton height={20} width="100%" />
</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
+272
View File
@@ -0,0 +1,272 @@
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 MultiSelect from './MultiSelect';
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 MultiSelectFieldType = {
type: 'multiselect';
name: string;
placeholder: string;
options: ItemsSelectType[];
defaultValue?: string[];
};
export type InputFieldType = {
type: 'input';
name: string;
placeholder: string;
defaultValue?: string;
};
export type FieldType = DateFieldType | SelectFieldType | MultiSelectFieldType | InputFieldType;
export type FilterValues = Record<string, 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 [multiSelectValues, setMultiSelectValues] = 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> = {};
const multiSelectFields: Record<string, string[]> = {};
fields.forEach(field => {
if (field.type === 'input' && initialValues[field.name]) {
inputFields[field.name] = initialValues[field.name] as string;
}
if (field.type === 'multiselect' && initialValues[field.name]) {
multiSelectFields[field.name] = Array.isArray(initialValues[field.name])
? (initialValues[field.name] as string[])
: [];
}
});
setInputValues(inputFields);
setMultiSelectValues(multiSelectFields);
} else {
const defaultValues: FilterValues = {};
const defaultInputs: Record<string, string> = {};
const defaultMultiSelects: Record<string, string[]> = {};
fields.forEach(field => {
if ('defaultValue' in field && field.defaultValue !== undefined) {
if (field.type === 'multiselect') {
defaultValues[field.name] = field.defaultValue as string[];
defaultMultiSelects[field.name] = field.defaultValue as string[];
} else {
defaultValues[field.name] = field.defaultValue;
if (field.type === 'input') {
defaultInputs[field.name] = field.defaultValue;
}
}
}
});
if (Object.keys(defaultValues).length > 0) {
setFilters(defaultValues);
setInputValues(defaultInputs);
setMultiSelectValues(defaultMultiSelects);
onChange(defaultValues);
}
}
}, [fields, initialValues, onChange]);
useEffect(() => {
const timeouts: Record<string, ReturnType<typeof setTimeout>> = {};
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 handleMultiSelectChange = (name: string, values: string[]) => {
const newFilters = { ...filters, [name]: values.length > 0 ? values : null };
setFilters(newFilters);
setMultiSelectValues(prev => ({ ...prev, [name]: values }));
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];
const currentMultiSelectValue = field.type === 'multiselect'
? (multiSelectValues[field.name] || (field.defaultValue as string[]) || [])
: undefined;
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'))}
defaulValue={currentValue as string || field.defaultValue || ''}
/>
);
case 'select':
return (
<div className='mt-1'>
<Select
key={field.name}
placeholder={field.placeholder}
onChange={(e) => handleChange(field.name, e.target.value)}
defaultValue={currentValue as string || field.defaultValue || ''}
items={field.options}
className='min-w-[170px]'
/>
</div>
);
case 'multiselect':
return (
<MultiSelect
key={field.name}
placeholder={field.placeholder}
onChange={(values) => handleMultiSelectChange(field.name, values)}
value={currentMultiSelectValue}
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 as string || 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;
+21
View File
@@ -0,0 +1,21 @@
import { type FC } from 'react'
import XIcon from '../assets/images/close-circle.svg'
type Props = {
label: string,
close: () => void,
}
const HeaderModal: FC<Props> = (props: Props) => {
return (
<div className='flex justify-between items-center'>
<div className='text-sm'>{props.label}</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={props.close} />
</div>
</div>
)
}
export default HeaderModal
+118
View File
@@ -0,0 +1,118 @@
import { type FC, type InputHTMLAttributes, useEffect, useState } from 'react'
import { clx } from '../helpers/utils';
import EyeIcon from '../assets/images/eye.svg'
import { 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',
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` والد
const syntheticEvent = {
...event,
target: {
...event.target,
name: event.target.name,
value: inputValue,
},
} as React.ChangeEvent<HTMLInputElement>;
props.onChange?.(syntheticEvent);
} 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='w-full relative 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' &&
<img onClick={() => setShowPassword((oldValue) => !oldValue)} src={EyeIcon} 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
+67
View File
@@ -0,0 +1,67 @@
import { type FC, useState } from 'react'
import DefaulModal from './DefaulModal'
import { useTranslation } from 'react-i18next'
import Button from './Button'
import Textarea from './Textarea'
type Props = {
isloading?: boolean,
close: () => void,
isOpen: boolean,
onConfrim: (text?: string) => void,
label?: string,
isHasDescription?: boolean
}
const ModalConfrim: FC<Props> = (props: Props) => {
const { t } = useTranslation('global')
const [description, setDescription] = useState<string>('')
return (
<DefaulModal
open={props.isOpen}
close={props.close}
title_header={t('confrim.subject')}
isHeader
>
<div className='mt-6'>
<div className='text-sm text-center'>
{
props.label ?
props.label
:
t('confrim.content')
}
{
props.isHasDescription &&
<div className='mt-4'>
<Textarea
placeholder={t('description')}
onChange={(e) => setDescription(e.target.value)}
value={description}
className='bg-transparent border border-gray-500 rounded-xl w-[300px] p-2'
/>
</div>
}
</div>
<div className='flex gap-4 justify-center mt-10'>
<Button
label={t('confrim.yes')}
onClick={() => props.onConfrim(props.isHasDescription ? description : undefined)}
isloading={props.isloading}
/>
<Button
label={t('confrim.cancel')}
className='bg-transparent text-black border border-primary'
onClick={props.close}
/>
</div>
</div>
</DefaulModal>
)
}
export default ModalConfrim
+108
View File
@@ -0,0 +1,108 @@
import { type FC, useState, useRef, useEffect } from 'react'
import { ArrowDown2 } from 'iconsax-react'
import { clx } from '../helpers/utils'
import { Checkbox } from './ui/checkbox'
import type { ItemsSelectType } from './Select'
type MultiSelectProps = {
className?: string
items: ItemsSelectType[]
placeholder?: string
label?: string
value?: string[]
onChange?: (values: string[]) => void
error_text?: string
}
const MultiSelect: FC<MultiSelectProps> = ({
className,
items,
placeholder,
label,
value = [],
onChange,
error_text,
}) => {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const handleToggle = (itemValue: string) => {
const newValues = value.includes(itemValue)
? value.filter((v) => v !== itemValue)
: [...value, itemValue]
onChange?.(newValues)
}
const displayText =
value.length === 0
? placeholder || 'انتخاب کنید'
: value.length === 1
? items.find((item) => item.value === value[0])?.label || placeholder
: `${value.length} مورد انتخاب شده`
return (
<div className="w-full min-w-[180px] relative" ref={containerRef}>
{label && <label className="text-sm">{label}</label>}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={clx(
'w-full text-black whitespace-nowrap border appearance-none border-border !bg-white px-2.5 h-10 text-sm rounded-[10px] bg-gray text-right flex items-center justify-between',
className,
label && 'mt-1',
isOpen && 'border-primary'
)}
>
<span className="whitespace-nowrap">{displayText}</span>
<ArrowDown2
size={16}
color="black"
className={clx('transition-transform', isOpen && 'rotate-180')}
/>
</button>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-border rounded-[10px] shadow-lg max-h-60 overflow-y-auto">
{items.map((item) => {
const isChecked = value.includes(item.value)
return (
<div
key={item.value}
className="flex items-center gap-2 px-3 py-2 hover:bg-secondary cursor-pointer"
onClick={() => handleToggle(item.value)}
>
<Checkbox checked={isChecked} />
<span className="text-sm text-foreground whitespace-nowrap">
{item.label}
</span>
</div>
)
})}
</div>
)}
{error_text && error_text !== '' && (
<div className="text-sm text-destructive mt-1">{error_text}</div>
)}
</div>
)
}
export default MultiSelect
+115
View File
@@ -0,0 +1,115 @@
import { getToken } from '@/config/func'
import { useSocket } from '@/pages/pager/hooks/useSocket'
import { type FC, useEffect, useCallback, useState } from 'react'
import type { NotificationPayload } from '@/types/notification.types'
import { NotificationSubject } from '@/types/notification.types'
import NotificationItem from '@/components/NotificationItem'
import { useQueryClient } from '@tanstack/react-query'
interface NotificationItemData extends NotificationPayload {
id: string
timestamp: number
}
const Notification: FC = () => {
const [notifications, setNotifications] = useState<NotificationItemData[]>([])
const [audioEnabled, setAudioEnabled] = useState(false)
const queryClient = useQueryClient()
const token = getToken()
const socketUrl = `${import.meta.env.VITE_SOCKET_URL}/notifications`
const { connect, socket, error } = useSocket(socketUrl, token!)
useEffect(() => {
if (error) {
console.error('❌ Notification: خطا در اتصال:', error)
}
}, [error])
useEffect(() => {
connect()
}, [connect])
useEffect(() => {
const enableAudio = async () => {
try {
const audio = new Audio('/mixkit-correct-answer-tone-2870.wav')
audio.volume = 0
await audio.play()
audio.pause()
audio.currentTime = 0
setAudioEnabled(true)
} catch {
console.warn('صدا هنوز فعال نشده است')
}
}
const handleUserInteraction = () => {
if (!audioEnabled) {
enableAudio()
}
}
window.addEventListener('click', handleUserInteraction, { once: true })
window.addEventListener('touchstart', handleUserInteraction, { once: true })
window.addEventListener('keydown', handleUserInteraction, { once: true })
return () => {
window.removeEventListener('click', handleUserInteraction)
window.removeEventListener('touchstart', handleUserInteraction)
window.removeEventListener('keydown', handleUserInteraction)
}
}, [audioEnabled])
const handleGetNotification = useCallback((data: NotificationPayload) => {
const newNotification: NotificationItemData = {
...data,
id: `${Date.now()}-${Math.random()}`,
timestamp: Date.now(),
}
setNotifications((prev) => [...prev, newNotification])
// Refetch بر اساس نوع نوتیفیکیشن
if (data.subject === NotificationSubject.PAGER_CREATED) {
queryClient.invalidateQueries({ queryKey: ['pagers'] })
} else if (data.subject === NotificationSubject.ORDER_CREATED) {
queryClient.invalidateQueries({ queryKey: ['orders'] })
}
}, [queryClient])
const handleRemoveNotification = useCallback((id: string) => {
setNotifications((prev) => prev.filter((notif) => notif.id !== id))
}, [])
useEffect(() => {
if (socket) {
socket.on('joined', () => null)
socket.on('notifications', handleGetNotification)
return () => {
socket.off('notifications', handleGetNotification)
}
}
}, [socket, handleGetNotification])
if (notifications.length === 0) {
return null
}
return (
<div className="fixed right-4 top-4 z-50 flex flex-col gap-2 max-w-[320px] w-full">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
id={notification.id}
subject={notification.subject}
body={notification.body}
onRemove={handleRemoveNotification}
audioEnabled={audioEnabled}
/>
))}
</div>
)
}
export default Notification
+97
View File
@@ -0,0 +1,97 @@
import { type FC, useEffect } from 'react'
import { CloseCircle, NotificationBing, ShoppingBag, MessageText1 } from 'iconsax-react'
import Button from '@/components/Button'
import { NotificationSubject } from '@/types/notification.types'
interface NotificationItemProps {
id: string
subject: string
body: string
onRemove: (id: string) => void
audioEnabled: boolean
}
const NotificationItem: FC<NotificationItemProps> = ({ id, subject, body, onRemove, audioEnabled }) => {
useEffect(() => {
if (!audioEnabled) {
return
}
const playNotificationSound = async () => {
try {
const audio = new Audio('/mixkit-correct-answer-tone-2870.wav')
audio.volume = 0.5
audio.addEventListener('error', (err) => {
console.error('❌ خطا در لود فایل صوتی:', err)
}, { once: true })
await audio.play()
} catch (err) {
console.warn('خطا در پخش صدا:', err)
}
}
playNotificationSound()
}, [audioEnabled])
const getNotificationConfig = (subjectType: string) => {
switch (subjectType) {
case NotificationSubject.PAGER_CREATED:
return {
icon: <MessageText1 color="white" size={16} variant="Bold" />,
title: 'پیجر جدید',
}
case NotificationSubject.ORDER_CREATED:
return {
icon: <ShoppingBag color="white" size={16} variant="Bold" />,
title: 'سفارش جدید',
}
default:
return {
icon: <NotificationBing color="white" size={16} variant="Bold" />,
title: 'اطلاعیه',
}
}
}
const config = getNotificationConfig(subject)
return (
<div className="bg-white border border-gray-200 rounded-lg p-3 shadow-sm transition-all duration-200 hover:shadow-md">
<div className="flex items-start gap-2.5">
<div className="bg-black p-1.5 rounded flex-shrink-0">
<div className="text-white">
{config.icon}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4 className="text-xs font-semibold text-gray-900">
{config.title}
</h4>
<button
onClick={() => onRemove(id)}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors p-0.5 rounded hover:bg-gray-100"
aria-label="بستن"
>
<CloseCircle color="gray" size={18} />
</button>
</div>
<p className="text-xs text-gray-700 leading-relaxed mb-2">
{body || 'نوتیفیکیشن جدید'}
</p>
<div className="flex justify-end">
<Button
onClick={() => onRemove(id)}
className="w-fit px-4 h-7 text-xs"
>
دیدم
</Button>
</div>
</div>
</div>
</div>
)
}
export default NotificationItem
+19
View File
@@ -0,0 +1,19 @@
import { type FC } from 'react'
import Logo from '../assets/images/logo_orig.svg'
import { useTranslation } from 'react-i18next'
const PageLoading: FC = () => {
const { t } = useTranslation('global')
return (
<div className='flex bg-transparent w-fit mx-auto flex-col gap-4 items-center'>
<img src={Logo} alt='logo' className='w-28' />
<div className='text-xs text-gray-700'>
{t('loading')}
</div>
</div>
)
}
export default PageLoading
+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;
+20
View File
@@ -0,0 +1,20 @@
import { type FC } from 'react'
type Props = {
isActive: boolean,
value: string | boolean,
onChange: (value: string | boolean) => void
}
const Radio: FC<Props> = (props: Props) => {
return (
<div onClick={() => props.onChange(props.value)} className='size-4 cursor-pointer rounded-full bg-[#EAEDF5] flex justify-center items-center'>
{
props.isActive &&
<div className='size-2 bg-black rounded-full'></div>
}
</div>
)
}
export default Radio
+31
View File
@@ -0,0 +1,31 @@
import { type FC } from 'react'
import Radio from './Radio'
type Props = {
items: {
label: string
value: string | boolean
}[]
selected: string | boolean
onChange: (value: string | boolean) => void
}
const RadioGroup: FC<Props> = (props: Props) => {
return (
<div className='flex gap-5 items-center text-xs'>
{
props.items.map((item, index) => (
<div key={index} className='flex gap-2 items-center'>
<Radio value={item.value} onChange={props.onChange} isActive={item.value === props.selected} />
<div className='mt-0.5'>
{item.label}
</div>
</div>
))
}
</div>
)
}
export default RadioGroup
+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-2 bg-white rounded-lg shadow-xl z-[5] min-w-[140px] md:min-w-[150px] border border-gray-200"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
}}
>
{actions.map((action, index) => (
<button
key={index}
onClick={() => handleActionClick(action)}
className={`w-full text-right px-3 py-2.5 text-sm flex items-center gap-2 hover:bg-gray-50 transition-colors ${index === 0 ? 'rounded-t-lg' : ''} ${index === actions.length - 1 ? 'rounded-b-lg' : ''} ${action.className || ''}`}
>
{action.icon && <span className="ml-2 flex-shrink-0">{action.icon}</span>}
<span className={`flex-1 ${action.label === 'حذف' ? 'text-red-500' : 'text-gray-700'}`}>{action.label}</span>
</button>
))}
</div>,
document.body
)
}
return (
<div className={className}>
<button
ref={buttonRef}
onClick={handleToggle}
className={`p-1.5 hover:bg-gray-100 rounded-lg transition-colors ${isOpen ? 'bg-gray-100' : ''}`}
aria-haspopup="menu"
aria-expanded={isOpen}
>
{trigger ? trigger : (
<More size={18} color="#6B7280" className="rotate-90" />
)}
</button>
{renderDropdown()}
</div>
)
}
export default RowActionsDropdown
+62
View File
@@ -0,0 +1,62 @@
import { type FC, type SelectHTMLAttributes } from 'react'
import { clx } from '../helpers/utils'
import { ArrowDown2 } from 'iconsax-react'
import Error from './Error'
export type ItemsSelectType = {
value: string,
label: string,
}
type Props = {
className?: string,
items: ItemsSelectType[],
error_text?: string,
placeholder?: string,
label?: string,
} & SelectHTMLAttributes<HTMLSelectElement>
const Select: FC<Props> = (props: Props) => {
return (
<div className='w-full relative'>
{
props.label &&
<label className='text-sm'>
{props.label}
</label>
}
<select {...props} className={clx(
'w-full text-black block border appearance-none border-border !bg-white px-2.5 h-10 text-sm rounded-[10px] bg-gray',
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='black' className={clx(
'absolute z-0 top-3 left-2',
props.label && 'top-10'
)} />
{
props.error_text && props.error_text !== '' ?
<Error
errorText={props.error_text}
/>
: null
}
</div>
)
}
export default Select
+19
View File
@@ -0,0 +1,19 @@
import { type FC } from 'react'
const ServiceSection: FC = () => {
return (
<div className='flex gap-4'>
<div className='size-10 rounded-xl bg-green-300'></div>
<div>
<div className='text-sm'>
دی منو
</div>
<div className='text-xs text-description'>
منو رستوران
</div>
</div>
</div>
)
}
export default ServiceSection
+33
View File
@@ -0,0 +1,33 @@
import { type FC } from 'react'
import { clx } from '@/helpers/utils'
type StatusVariant = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'active' | 'inactive'
type Props = {
variant: StatusVariant
label: string
}
const Status: FC<Props> = ({ variant, label }) => {
const variantStyles: Record<StatusVariant, string> = {
success: 'bg-green-100 text-green-600',
error: 'bg-red-100 text-red-600',
warning: 'bg-yellow-100 text-yellow-600',
info: 'bg-blue-100 text-blue-600',
pending: 'bg-gray-100 text-gray-600',
active: 'bg-green-100 text-green-600',
inactive: 'bg-gray-100 text-gray-400',
}
return (
<div className={clx(
'w-fit py-1 px-2 text-xs h-fit rounded-full',
variantStyles[variant]
)}>
{label}
</div>
)
}
export default Status
+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
+22
View File
@@ -0,0 +1,22 @@
import { type FC } from 'react'
import { clx } from '../helpers/utils'
type Props = {
variant: 'success' | 'error' | 'warning',
text: string,
}
const StatusWithText: FC<Props> = (props: Props) => {
return (
<div className={clx(
'w-fit py-1 px-2 text-xs h-fit rounded-full',
props.variant === 'success' && 'bg-green-100 text-success',
props.variant === 'error' && 'bg-red-100 text-red-400',
props.variant === 'warning' && 'bg-yellow-100 text-yellow-400',
)}>
{props.text}
</div>
)
}
export default StatusWithText
+50
View File
@@ -0,0 +1,50 @@
import { Fragment } from 'react'
import { Switch } from '@headlessui/react'
import MoonLoader from 'react-spinners/MoonLoader'
interface Props {
active: boolean,
onChange: (value: boolean) => void,
isLoading?: boolean,
label?: string,
}
const SwitchComponent = (props: Props) => {
const handleChange = (value: boolean) => {
props.onChange(value)
}
if (props.isLoading) {
return (
<MoonLoader size={20} color='black' className='ml-4' />
)
}
return (
<div className='dltr w-fit flex gap-2 items-center'>
<Switch checked={props.active} onChange={handleChange} as={Fragment}>
{({ checked }) => (
<button
className={`${checked ? 'bg-primary' : 'bg-gray-200'
} relative inline-flex h-6 w-11 items-center rounded-full`}
>
<span className="sr-only">Enable notifications</span>
<span
className={`${checked ? 'translate-x-6' : 'translate-x-1'
} inline-block h-4 w-4 transform rounded-full bg-white transition`}
/>
</button>
)}
</Switch>
{
props.label &&
<div className='text-sm'>
: {props.label}
</div>
}
</div>
)
}
export default SwitchComponent
+322
View File
@@ -0,0 +1,322 @@
import React, { 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 (
<DefaultTableSkeleton tdCount={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} trCount={emptyRowsCount} />
);
}
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;
+50
View File
@@ -0,0 +1,50 @@
import { type FC, type ReactNode } from 'react'
import { clx } from '../helpers/utils'
import { Swiper, SwiperSlide } from 'swiper/react'
type Item = {
icon: ReactNode,
label: string,
value: string,
}
type Props = {
items: Item[],
onChange: (value: string) => void,
active: string,
}
const Tabs: FC<Props> = (props: Props) => {
const SWIPER_SLIDE_STYLE = {
overflow: 'visible !important',
display: 'flex',
}
return (
<Swiper
slidesPerView='auto'
spaceBetween={30}
className='px-10 max-w-full w-fit items-center text-description mx-auto backdrop-blur-md border-2 border-white gap-10 flex h-[80px] rounded-[32px] bg-white/45'>
{
props.items.map((item: Item, index: number) => {
return (
<SwiperSlide style={SWIPER_SLIDE_STYLE} onClick={() => props.onChange(item.value)} key={item.value} className={clx(
'flex flex-col max-w-fit mt-[15px] items-center gap-2 cursor-pointer',
index === 0 && 'pr-[30px]',
// index === props.items.length - 1 && 'pl-[30px]',
props.active === item.value && 'text-black'
)}>
{item.icon}
<div className='text-xs'>
{item.label}
</div>
</SwiperSlide>
)
})
}
</Swiper>
)
}
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
+39
View File
@@ -0,0 +1,39 @@
import { type FC, type InputHTMLAttributes } from 'react'
import Error from './Error'
import { clx } from '../helpers/utils'
type Props = {
label?: string,
error_text?: string
} & InputHTMLAttributes<HTMLTextAreaElement>
const Textarea: FC<Props> = (props: Props) => {
return (
<div className='w-full relative'>
{
props.label &&
<div className='text-sm' >
{props.label}
</div>
}
<textarea
{...props}
className={clx(
'border border-border leading-7 w-full rounded-xl px-4 py-3 min-h-[100px] mt-1 text-xs',
props.className
)}
>
</textarea>
{
props.error_text &&
<Error errorText={props.error_text} />
}
</div>
)
}
export default Textarea
+91
View File
@@ -0,0 +1,91 @@
import { useState, useRef, useEffect, type FC } from 'react';
import { clx, formatTimeToHHmm } from '../helpers/utils';
import { Clock } from 'iconsax-react';
import Error from './Error';
type Props = {
onChange: (time: string) => void;
defaultValue?: string;
error_text?: string;
placeholder?: string;
className?: string;
label?: string;
};
const TimePickerComponent: FC<Props> = (props: Props) => {
const [inputValue, setInputValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (props.defaultValue) {
const timeValue = formatTimeToHHmm(props.defaultValue.replace('.', ':'));
setInputValue(timeValue);
}
}, [props.defaultValue]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const timeValue = e.target.value; // فرمت: HH:mm
setInputValue(timeValue);
if (timeValue) {
// فرمت HH:mm را مستقیماً ارسال می‌کنیم
props.onChange(timeValue);
} else {
props.onChange('');
}
};
const handleWrapperClick = () => {
inputRef.current?.showPicker?.();
inputRef.current?.focus();
inputRef.current?.click();
};
return (
<div className="w-full">
{props.label && (
<div className='text-sm'>
{props.label}
</div>
)}
<div className={clx(
'relative',
props.label && 'mt-1.5'
)}>
<div
onClick={handleWrapperClick}
className={clx(
'w-full bg-white h-10 text-black flex items-center px-4 pl-10 pr-4 text-xs rounded-xl border border-border cursor-pointer relative',
'hover:border-primary transition-colors',
props.className
)}
>
<input
ref={inputRef}
type="time"
value={inputValue}
onChange={handleChange}
className="absolute opacity-0 w-full h-full cursor-pointer"
/>
{!inputValue && (
<span className="absolute right-4 text-description pointer-events-none">
{props.placeholder || 'انتخاب زمان'}
</span>
)}
{inputValue && (
<span className="absolute right-4 pointer-events-none text-black">
{inputValue}
</span>
)}
<Clock size={20} color="#8C90A3" className='absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none z-10' />
</div>
{props.error_text && props.error_text !== '' && (
<Error errorText={props.error_text} />
)}
</div>
</div>
);
};
export default TimePickerComponent;
+18
View File
@@ -0,0 +1,18 @@
import { type FC } from 'react'
type Props = {
title: string,
}
const TitleLine: FC<Props> = (props: Props) => {
return (
<div className='w-full text-sm flex items-center gap-4'>
<div className='whitespace-nowrap'>{props.title}</div>
<svg style={{ width: '100%', height: 1 }} xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<line x1="0" y1="0" x2="100%" y2="0" stroke="#aaa" strokeWidth="1" strokeDasharray="5 3" />
</svg>
</div>
)
}
export default TitleLine
+30
View File
@@ -0,0 +1,30 @@
import { Trash } from 'iconsax-react'
import { type FC, useState } from 'react'
import ModalConfrim from './ModalConfrim'
interface Props {
onDelete: () => void,
isloading?: boolean
}
const TrashWithConfrim: FC<Props> = ({ onDelete, isloading = false }) => {
const [isConfirm, setIsConfirm] = useState(false)
return (
<div>
<Trash
onClick={() => setIsConfirm(true)}
className='size-5'
color='#888'
/>
<ModalConfrim
isOpen={isConfirm}
close={() => setIsConfirm(false)}
onConfrim={() => onDelete()}
isloading={isloading}
/>
</div>
)
}
export default TrashWithConfrim
+91
View File
@@ -0,0 +1,91 @@
import { type FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { CloseCircle } from 'iconsax-react'
type Props = {
label: string,
isMultiple?: boolean,
onChange?: (file: File[]) => void;
isReset?: boolean;
}
const UploadBox: FC<Props> = (props: Props) => {
const { t } = useTranslation('global')
const [files, setFiles] = useState<File[]>([])
const onDrop = useCallback((acceptedFiles: File[]) => {
if (props.isMultiple) {
const array = [...files]
array.push(acceptedFiles[0])
setFiles(array)
if (props.onChange) {
props.onChange(array);
}
} else {
setFiles([acceptedFiles[0]])
if (props.onChange) {
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)
if (props.onChange) {
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('select_file')}
</button>
<div className='lg:flex gap-2 hidden'>
{
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>
<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
+170
View File
@@ -0,0 +1,170 @@
import { CloseCircle, DocumentUpload, Gallery } from 'iconsax-react'
import { type FC, useCallback, useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { clx } from '../helpers/utils';
type Props = {
label: string;
onChange: (file: File[]) => void;
isMultiple?: boolean;
isFile?: boolean,
preview?: string[],
onChangePreview?: (preview: string[]) => void,
getCover?: (url: string) => void,
coverUrl?: string,
imageSize?: 'small' | 'medium' | 'large'
}
const UploadBoxDraggble: FC<Props> = (props: Props) => {
const { t } = useTranslation('global')
const [files, setFiles] = useState<File[]>([])
const [perviews, setPerviews] = useState<string[]>(props.preview ? props.preview : [])
const [isFill, setIsFill] = useState<boolean>(false)
const [cover, setCover] = useState<string>(props.coverUrl ? props.coverUrl : '')
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 handleDelete = (index: number) => {
const array = [...files]
array.splice(index, 1)
setFiles(array)
props.onChange(array)
}
const handleDeletePreview = (index: number) => {
const array = [...perviews];
array.splice(index, 1);
setPerviews(array);
if (props.onChangePreview) {
props.onChangePreview(array);
}
}
useEffect(() => {
if (props.preview && props.preview.length > 0 && !isFill) {
setPerviews(props.preview)
console.log('preview', props.preview);
setIsFill(true)
}
}, [props.preview, isFill])
useEffect(() => {
setCover(props.coverUrl ? props.coverUrl : '')
}, [props.coverUrl])
return (
<div>
<div {...getRootProps()} className='w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl'>
<input {...getInputProps()} />
<Gallery
className='size-8'
color='#8C90A3'
/>
<div className='text-description text-xs'>
{props.label}
</div>
<div className='flex items-center gap-2'>
<DocumentUpload
className='size-4'
color='black'
/>
<div className='text-xs'>{t('upload')}</div>
</div>
</div>
{
files.length > 0 || perviews ? (
<div className={clx(
'mt-4',
props.imageSize === 'large' ? 'grid grid-cols-2 gap-4' : 'flex gap-4 items-center flex-wrap'
)}>
{
perviews && perviews.map((item, index) => {
const imageSizeClass = props.imageSize === 'large'
? 'w-full h-32 rounded-lg'
: props.imageSize === 'medium'
? 'size-20 rounded-lg'
: 'size-10 rounded-full'
return (
<div key={index} className='flex items-center gap-2'>
<div key={index} className='flex relative items-center gap-2'>
<img onClick={() => {
if (props.getCover) {
setCover(item)
props.getCover(item)
}
}} src={item}
className={clx(
imageSizeClass,
'object-cover cursor-pointer',
cover === item ? 'border-2 border-red-400' : ''
)} />
<div onClick={() => handleDeletePreview(index)} className='absolute -left-2 -top-2 shadow-md bg-white size-5 rounded-full flex justify-center items-center cursor-pointer z-10'>
<CloseCircle className='size-4 ' color='red' />
</div>
</div>
</div>
)
})
}
{
files.map((file, index) => {
if (props.isFile) {
return (
<div key={index} className='flex border p-2 rounded-lg items-center gap-2'>
<div className='flex relative items-center gap-2'>
<div className='text-xs'>{file.name}</div>
<div className='absolute -left-4 -top-4 shadow-md bg-white size-5 rounded-full flex justify-center items-center cursor-pointer'>
<CloseCircle onClick={() => handleDelete(index)} className='size-4 ' color='red' />
</div>
</div>
</div>
)
}
else {
const imageSizeClass = props.imageSize === 'large'
? 'w-full h-32 rounded-lg'
: props.imageSize === 'medium'
? 'size-20 rounded-lg'
: 'size-10 rounded-full'
return (
<div key={index} className='flex items-center gap-2'>
<div key={index} className='flex relative items-center gap-2'>
<img src={URL.createObjectURL(file)} alt={file.name} className={clx(imageSizeClass, 'object-cover')} />
<div className='absolute -left-2 -top-2 shadow-md bg-white size-5 rounded-full flex justify-center items-center cursor-pointer z-10'>
<CloseCircle onClick={() => handleDelete(index)} className='size-4 ' color='red' />
</div>
</div>
</div>
)
}
})
}
</div>
) : null
}
</div>
)
}
export default UploadBoxDraggble
+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;
}
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "@radix-ui/react-icons"
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 shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring 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")}
>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+1
View File
@@ -0,0 +1 @@
export const SIDEBAR_SIZE_LEFT = 300;
+87
View File
@@ -0,0 +1,87 @@
export const Pages = {
auth: {
login: "/auth/login",
register: "/auth/register",
forgotPassword: "/auth/forgot",
},
dashboard: "/dashboard",
schedule: {
list: "/schedule/list",
update: "/schedule/update/",
create: "/schedule/create",
},
payment_methods: {
list: "/payment_methods/list",
add: "/payment_methods/create",
update: "/payment_methods/update/",
},
foods: {
list: "/foods/list",
add: "/foods/add",
category: "/foods/category",
update: "/foods/update/",
},
orders: {
list: "/orders/list",
add: "/orders/add",
detail: "/orders/detail/",
},
customers: {
list: "/customers/list",
add: "/customers/add",
detail: "/customers/detail/",
update: "/customers/update/",
},
discount: {
list: "/discounts/list",
add: "/discounts/add",
detail: "/discounts/detail/",
update: "/discounts/update/",
},
announcements: {
list: "/announcements/list",
add: "/announcements/add",
detail: "/announcements/detail/",
},
reports: {
list: "/reports/list",
},
comments: {
list: "/comments/list",
detail: "/comments/detail/",
},
setting: "/setting",
roles: {
list: "/roles/list",
add: "/roles/add",
update: "/roles/update/",
},
admins: {
list: "/admins/list",
add: "/admins/add",
update: "/admins/update/",
},
shipment_methods: {
list: "/shipment_methods/list",
add: "/shipment_methods/add",
update: "/shipment_methods/update/",
},
coupons: {
list: "/coupons/list",
add: "/coupons/add",
update: "/coupons/update/",
},
reviews: {
list: "/reviews/list",
detail: "/reviews/detail/",
},
pagers: {
list: "/pagers/list",
},
notifications: {
list: "/notifications/list",
},
statistics: {
list: "/statistics",
},
};
+6
View File
@@ -0,0 +1,6 @@
export const SideBarItemHasSubMenu = [
"foods",
"orders",
"customers",
"discounts",
];
+124
View File
@@ -0,0 +1,124 @@
import axios from "axios";
import {
getToken,
getRefreshToken,
setToken,
setRefreshToken,
removeToken,
removeRefreshToken,
} from "./func";
import { refreshToken } from "../pages/auth/service/AuthService";
declare global {
interface Window {
isRefreshingToken?: boolean;
isRefreshTokenExpired?: boolean;
}
}
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
});
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error?.response?.status === 401 && !originalRequest._retry) {
// اگر refresh token منقضی شده باشد، دیگر تلاش نکن
if (window.isRefreshTokenExpired) {
return Promise.reject(error);
}
// بررسی کن که آیا خطا از refresh endpoint است
const isRefreshEndpoint = originalRequest?.url?.includes('/admin/auth/refresh');
// اگر خطا از refresh endpoint است، refresh token منقضی شده
if (isRefreshEndpoint) {
window.isRefreshTokenExpired = true;
await removeToken();
await removeRefreshToken();
window.location.href = `/`;
return Promise.reject(error);
}
if (window.isRefreshingToken) {
// Wait for the refresh to complete
await new Promise((resolve) => {
const checkComplete = setInterval(() => {
if (!window.isRefreshingToken) {
clearInterval(checkComplete);
resolve(true);
}
}, 100);
});
return axiosInstance(originalRequest);
}
originalRequest._retry = true;
window.isRefreshingToken = true;
try {
const refreshTokenValue = await getRefreshToken();
if (!refreshTokenValue) {
window.isRefreshTokenExpired = true;
await removeRefreshToken();
await removeToken();
window.location.href = `/`;
return Promise.reject(error);
}
const data = await refreshToken({
refreshToken: refreshTokenValue,
});
if (data?.data?.accessToken?.token) {
await setToken(data.data.accessToken.token);
if (data?.data?.refreshToken?.token) {
await setRefreshToken(data.data.refreshToken.token);
}
// Reset expired flag if refresh was successful
window.isRefreshTokenExpired = false;
originalRequest.headers.Authorization = `Bearer ${data.data.accessToken.token}`;
return axiosInstance(originalRequest);
} else {
window.isRefreshTokenExpired = true;
await removeRefreshToken();
await removeToken();
window.location.href = `/`;
return Promise.reject(error);
}
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
// بررسی کن که آیا خطا از refresh endpoint است
const isRefreshError = (refreshError as { response?: { config?: { url?: string }; status?: number } })?.response?.config?.url?.includes('/admin/auth/refresh');
const is401Error = (refreshError as { response?: { status?: number } })?.response?.status === 401;
// اگر refresh token خودش 401 خورده، آن را منقضی شده علامت بزن
if (isRefreshError || is401Error) {
window.isRefreshTokenExpired = true;
}
await removeToken();
await removeRefreshToken();
window.location.href = `/`;
return Promise.reject(refreshError);
} finally {
window.isRefreshingToken = false;
}
}
return Promise.reject(error);
}
);
axiosInstance.interceptors.request.use(async function (config) {
const tokenValue = await getToken();
const token = tokenValue ? tokenValue : "";
config.headers.Authorization = "Bearer " + token;
return config;
});
export default axiosInstance;
+74
View File
@@ -0,0 +1,74 @@
export function isEmail(input: string): boolean {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailRegex.test(input);
}
export function NumberFormat(number: number): string {
return Intl.NumberFormat("fa-IR").format(number);
}
export const getToken = () => {
return localStorage.getItem(import.meta.env.VITE_TOKEN_NAME);
};
export const getRefreshToken = () => {
return localStorage.getItem(import.meta.env.VITE_REFRESH_TOKEN_NAME);
};
export const setToken = (token: string) => {
localStorage.setItem(import.meta.env.VITE_TOKEN_NAME, token);
};
export const setRefreshToken = (refreshToken: string) => {
localStorage.setItem(import.meta.env.VITE_REFRESH_TOKEN_NAME, refreshToken);
};
export const removeToken = () => {
localStorage.removeItem(import.meta.env.VITE_TOKEN_NAME);
};
export const removeRefreshToken = () => {
localStorage.removeItem(import.meta.env.VITE_REFRESH_TOKEN_NAME);
};
export const timeAgo = (date: string | Date): string => {
const now = new Date();
const past = new Date(date);
const diff = Math.floor((now.getTime() - past.getTime()) / 1000); // اختلاف بر حسب ثانیه
const units = [
{ name: "سال", value: 60 * 60 * 24 * 365 },
{ name: "ماه", value: 60 * 60 * 24 * 30 },
{ name: "روز", value: 60 * 60 * 24 },
{ name: "ساعت", value: 60 * 60 },
{ name: "دقیقه", value: 60 },
{ name: "ثانیه", value: 1 },
];
for (const unit of units) {
const amount = Math.floor(diff / unit.value);
if (amount >= 1) return `${amount} ${unit.name} پیش`;
}
return "همین الان";
};
export interface IGeneralError {
status: number;
success: boolean;
error: {
message: string[];
};
}
export const extractErrorMessage = (
error: Error | unknown,
fallbackMessage: string = "خطایی رخ داده است"
): string => {
try {
const axiosError = error as { response?: { data: IGeneralError } };
return axiosError?.response?.data?.error?.message?.[0] || fallbackMessage;
} catch {
return fallbackMessage;
}
};
+55
View File
@@ -0,0 +1,55 @@
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
export const formatDateShort = (dateString: string): string => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
};
export const formatOptionalDate = (
value?: string | null,
options?: Intl.DateTimeFormatOptions
): string => {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
return new Intl.DateTimeFormat(
"fa-IR",
options ?? {
year: "numeric",
month: "2-digit",
day: "2-digit",
}
).format(date);
};
export const formatFaNumber = (value?: number | null): string => {
if (value === undefined || value === null || Number.isNaN(value)) {
return "0";
}
return value.toLocaleString("fa-IR");
};
export const formatPrice = (price: number): string => {
return (
new Intl.NumberFormat("fa-IR", {
minimumFractionDigits: 0,
}).format(price) + " تومان"
);
};
+59
View File
@@ -0,0 +1,59 @@
import type { PermissionType } from '@/pages/roles/types/Types';
/**
* Checks if user has a specific permission
* @param permissions - Array of user permissions
* @param permissionName - Name of the permission to check
* @returns boolean indicating if user has the permission
*/
export const hasPermission = (
permissions: PermissionType[] | undefined,
permissionName: string
): boolean => {
if (!permissions || permissions.length === 0) {
return false;
}
return permissions.some(
(permission) => permission.name === permissionName
);
};
/**
* Checks if user has any of the specified permissions
* @param permissions - Array of user permissions
* @param permissionNames - Array of permission names to check
* @returns boolean indicating if user has at least one of the permissions
*/
export const hasAnyPermission = (
permissions: PermissionType[] | undefined,
permissionNames: string[]
): boolean => {
if (!permissions || permissions.length === 0 || permissionNames.length === 0) {
return false;
}
return permissionNames.some((permissionName) =>
hasPermission(permissions, permissionName)
);
};
/**
* Checks if user has all of the specified permissions
* @param permissions - Array of user permissions
* @param permissionNames - Array of permission names to check
* @returns boolean indicating if user has all of the permissions
*/
export const hasAllPermissions = (
permissions: PermissionType[] | undefined,
permissionNames: string[]
): boolean => {
if (!permissions || permissions.length === 0 || permissionNames.length === 0) {
return false;
}
return permissionNames.every((permissionName) =>
hasPermission(permissions, permissionName)
);
};
+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[];
};
};
};
};
+26
View File
@@ -0,0 +1,26 @@
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(" "));
};
/**
* Converts time string to HH:mm format
* @param time - Time string in various formats (HH:mm:ss, HH:mm, etc.)
* @returns Time string in HH:mm format
*/
export const formatTimeToHHmm = (time: string): string => {
if (!time) return '';
// اگر زمان به فرمت HH:mm:ss باشد، فقط HH:mm را برمی‌گرداند
if (time.includes(':') && time.split(':').length === 3) {
return time.substring(0, 5);
}
// اگر به فرمت HH:mm باشد، همان را برمی‌گرداند
return time;
};
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
export interface ICountDown {
m: number;
s: number;
}
/**
*
* @param minutes how many minutes.
* @param autoplay by default true. if you want play coundDown after an action, you can set it to false and manually play it.
* @param onEnd you can pass a callback function that will trigger at the END.
* @returns
*/
export function useCountDown(minutes: number, autoplay = true, onEnd = () => {}) {
const [countDown, setCountDown] = useState<ICountDown>({m: minutes, s: 0});
const [stop, setStop] = useState<boolean>(!autoplay);
useEffect(() => {
const handler = setTimeout(() => {
if (countDown.m !== 0 || countDown.s !== 0) {
setCountDown(calculateCountDown(countDown));
} else {
onEnd();
}
}, 1000);
return () => {
clearTimeout(handler);
};
}, [countDown]);
const reset = () => {
setCountDown({m: minutes, s: 0});
};
const pause = () => {
setStop(true);
};
const play = () => {
setStop(false);
setCountDown({...countDown});
};
const calculateCountDown = (st: ICountDown): ICountDown => {
if (stop) {
return countDown;
}
const timeLeft = { m: 0, s: 0 };
const remain = st.m * 60 + st.s - 1;
if (remain > 0) {
timeLeft.m = Math.floor((remain) / 60);
timeLeft.s = remain - timeLeft.m * 60;
return timeLeft;
} else {
if (remain === 0 && countDown.s === 1) {
return timeLeft;
} else {
return countDown;
}
}
};
return {
value: `${countDown.m > 9 ? countDown.m : '0' + countDown.m}:${countDown.s > 9 ? countDown.s : '0' + countDown.s}`,
reset,
pause,
play
};
}
+180
View File
@@ -0,0 +1,180 @@
import { getToken } from "@/config/func";
import { useEffect, useRef, useState, useCallback } from "react";
import type { Socket } from "socket.io-client";
import { io } from "socket.io-client";
import type { NotificationPayload } from "@/types/notification.types";
export type { NotificationPayload };
export interface NotificationSocketOptions {
serverUrl?: string;
token: string;
autoConnect?: boolean;
onNotification?: (notification: NotificationPayload) => void;
onJoined?: (data: { room: string; message: string }) => void;
onError?: (error: { message: string }) => void;
onConnect?: () => void;
onDisconnect?: (reason: string) => void;
}
export interface NotificationSocketState {
isConnected: boolean;
isConnecting: boolean;
room: string | null;
error: string | null;
}
export function useNotificationSocket(options: NotificationSocketOptions) {
const {
serverUrl = import.meta.env.VITE_SOCKET_URL,
token = getToken(),
autoConnect = true,
onNotification,
onJoined,
onError,
onConnect,
onDisconnect,
} = options;
const socketRef = useRef<Socket | null>(null);
const [state, setState] = useState<NotificationSocketState>({
isConnected: false,
isConnecting: false,
room: null,
error: null,
});
// Store callbacks in refs to avoid recreating socket on callback changes
const callbacksRef = useRef({
onNotification,
onJoined,
onError,
onConnect,
onDisconnect,
});
useEffect(() => {
callbacksRef.current = {
onNotification,
onJoined,
onError,
onConnect,
onDisconnect,
};
}, [onNotification, onJoined, onError, onConnect, onDisconnect]);
const connect = useCallback(() => {
if (socketRef.current?.connected) {
return;
}
if (!token) {
setState((prev) => ({
...prev,
error: "Token is required for connection",
isConnecting: false,
}));
return;
}
setState((prev) => ({ ...prev, isConnecting: true, error: null }));
// Clean token - remove "Bearer " prefix if present
const cleanToken = token.startsWith("Bearer ") ? token.substring(7) : token;
const socket = io(`${serverUrl}/notifications`, {
transports: ["websocket", "polling"],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
auth: {
token: cleanToken,
},
});
socket.on("connect", () => {
setState((prev) => ({
...prev,
isConnected: true,
isConnecting: false,
error: null,
}));
callbacksRef.current.onConnect?.();
});
socket.on("disconnect", (reason: string) => {
setState((prev) => ({
...prev,
isConnected: false,
isConnecting: false,
room: null,
}));
callbacksRef.current.onDisconnect?.(reason);
});
socket.on("connect_error", (error: Error) => {
setState((prev) => ({
...prev,
isConnected: false,
isConnecting: false,
error: error.message || "Connection failed",
}));
callbacksRef.current.onError?.({
message: error.message || "Connection failed",
});
});
socket.on("joined", (data: { room: string; message: string }) => {
setState((prev) => ({
...prev,
room: data.room,
}));
callbacksRef.current.onJoined?.(data);
});
socket.on("notifications", (data: NotificationPayload) => {
callbacksRef.current.onNotification?.(data);
});
socket.on("error", (error: { message: string }) => {
setState((prev) => ({
...prev,
error: error.message,
}));
callbacksRef.current.onError?.(error);
});
socketRef.current = socket;
}, [serverUrl, token]);
const disconnect = useCallback(() => {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setState({
isConnected: false,
isConnecting: false,
room: null,
error: null,
});
}
}, []);
useEffect(() => {
if (autoConnect && token) {
connect();
}
return () => {
disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoConnect, token]);
return {
...state,
connect,
disconnect,
socket: socketRef.current,
};
}
+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;
};
+50
View File
@@ -0,0 +1,50 @@
import { useMemo } from "react";
import { useGetPermissions } from "@/pages/roles/hooks/useRolesData";
import {
hasPermission,
hasAnyPermission,
hasAllPermissions,
} from "@/helpers/permissions";
/**
* Custom hook for managing user permissions
* @returns Object with permissions data and helper functions
*/
export const usePermissions = () => {
const { data: permissionsResponse, isLoading, error } = useGetPermissions();
const permissions = useMemo(() => {
return permissionsResponse?.data || [];
}, [permissionsResponse]);
/**
* Checks if user has a specific permission
*/
const checkPermission = (permissionName: string): boolean => {
return hasPermission(permissions, permissionName);
};
/**
* Checks if user has any of the specified permissions
*/
const checkAnyPermission = (permissionNames: string[]): boolean => {
return hasAnyPermission(permissions, permissionNames);
};
/**
* Checks if user has all of the specified permissions
*/
const checkAllPermissions = (permissionNames: string[]): boolean => {
return hasAllPermissions(permissions, permissionNames);
};
return {
permissions,
isLoading,
error,
checkPermission,
checkAnyPermission,
checkAllPermissions,
hasPermission: checkPermission,
};
};
+104
View File
@@ -0,0 +1,104 @@
@import "tailwindcss";
@import "react-loading-skeleton/dist/skeleton.css";
html {
direction: rtl;
}
body {
font-family: irancell;
margin: 0;
padding: 0;
width: 100%;
direction: rtl;
overflow-x: hidden !important;
background: #eceef6;
font-weight: 300;
overflow: hidden;
}
html,
body,
#root {
min-height: 100% !important;
height: 100%;
}
* {
outline: none;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
input::placeholder,
textarea::placeholder {
color: #888888;
}
@theme {
--color-primary: black;
--color-primary-foreground: white;
--color-secondary: #f4f5f9;
--color-border: #d0d0d0;
--color-description: #888888;
--color-header: #c3c7dd;
--color-success: #00ba4b;
--width-sidebar: 300px;
--border-radius-2.5: 10px;
}
.dltr {
direction: ltr;
}
tbody tr:nth-child(odd) {
background: rgba(233, 235, 244, 0.2) !important;
}
.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 sm:flex-row sm:gap-5 gap-5;
}
.time-picker-input::-webkit-calendar-picker-indicator {
display: none !important;
-webkit-appearance: none;
}
.time-picker-input::-webkit-inner-spin-button,
.time-picker-input::-webkit-outer-spin-button {
display: none !important;
-webkit-appearance: none;
}
.time-picker-input {
-webkit-appearance: none;
-moz-appearance: textfield;
}
.modalGlass3 {
background: rgba(62, 61, 61, 0.2);
background-blend-mode: multiply;
backdrop-filter: blur(44px);
}
+891
View File
@@ -0,0 +1,891 @@
{
"auth": {
"welcome": "خوش آمدید",
"enter_information": "برای ثبت نام اطلاعات خود را وارد کنید.",
"user_info": "اطلاعات کاربر",
"name": "نام",
"enter_name": "نام خود را وارد کنید",
"family": "نام خانوادگی",
"enter_family": "نام خانوادگی خود را وارد کنید",
"select_date": "انتخاب تاریخ",
"dateofbirth": "تاریخ تولد",
"national_code": "کد ملی",
"national_code_enter": "کد ملی خود را وارد کنید",
"email": "ایمیل",
"enter_email": "ایمیل خود را وارد کنید",
"phonen_number": "شماره تماس",
"enter_phone_number": "شماره تماس خود را وارد کنید",
"password": "رمز عبور",
"enter_password": "رمز عبور انتخابی خود را وارد کنید",
"next": "ادامه",
"before_registered": "آیا قبلا ثبت نام کردید؟",
"login": "ورود",
"enter_info_login": "لطفا اطلاعات خود را وارد کنید.",
"mobile_or_email": " شماره موبایل یا ایمیل",
"enter_mobile_or_email": " شماره موبایل یا ایمیل خود را وارد کنید",
"mobile": "شماره موبایل",
"enter_mobile": "شماره موبایل خود را وارد کنید",
"otp_sent": "کد تایید ارسال شد",
"have_account": "آیا حساب کاربری ندارید؟",
"register": "ثبت نام",
"enter_otp": "کد تایید را وارد کنید",
"verfify_code_text_1": "کد تایید برای ",
"verfify_code_text_2": "ارسال شد.",
"change_number": "تغییر شماره",
"verify_code": "کد تایید ",
"counter_otp": "مانده تا دریافت مجدد کد",
"login_with_password": "ورود با رمز عبور",
"enter_password_step3": "رمز عبور را وارد کنید",
"enter_your_password": "رمز عبور خود را وارد کنید.",
"forgot_password": "فراموشی رمز عبور",
"login_with_otp": "ورود با رمز عبور یکبار مصرف",
"slug": "نام کاربری",
"enter_slug": "نام کاربری خود را وارد کنید"
},
"errors": {
"required": "این فیلد اجباری می باشد",
"upload_image": "تصاویر مربوطه باید انتخاب شوند"
},
"description": "توضیحات",
"sidebar": {
"menu": "منو",
"mainpage": "صفحه اصلی",
"schedule": "زمان سفارش گیری",
"foods": "غذا ها",
"orders": "سفارشات",
"customers": "مشتریان",
"discount": "کد تخفیف",
"other": "سایر",
"announcements": "اطلاعیه ها",
"reports": "گزارش اشکال",
"comments": "نظرات",
"setting": "تنظیمات",
"logout": "خروج",
"roles": "سطح دسترسی ها",
"payment_methods": "روش های پرداخت",
"admins": "مدیران",
"shipment_methods": "روش های ارسال",
"reviews": "نظرات",
"pagers": "پیجرها",
"notifications": "تنظیمات اعلانات",
"statistics": "گزارشات"
},
"shipment": {
"dineIn": "سرو در رستوران",
"customerPickup": "دریافت از محل",
"deliveryCar": "تحویل به خودرو",
"deliveryCourier": "بیرون بر"
},
"slider": {
"new_slider": "اسلایدر جدید",
"submit": "ثبت اسلایدر",
"title": "عنوان",
"link": "لینک",
"description": "توضیحات",
"image": "تصویر",
"order": "ترتیب",
"isActive": "فعال / غیرفعال",
"image_required": "تصویر الزامی می باشد",
"list_slider": "لیست اسلایدر ها",
"submit_slider": "اسلایدر جدید",
"active": "فعال",
"inactive": "غیرفعال",
"slider_deleted": "اسلایدر با موفقیت حذف شد"
},
"submenu": {
"foods_list": "لیست غذا ها",
"add_food": "افزودن غذا",
"categories": "دسته بندی ها",
"orders_list": "لیست سفارشات",
"add_order": "افزودن سفارش",
"discount_list": "لیست تخفیف",
"add_discount": "افزودن تخفیف",
"customers_list": "لیست مشتریان",
"add_customer": "افزودن مشتری"
},
"guide": {
"list_guide": "لیست راهنمای سرویس",
"submit_guide": "افزودن راهنمای سرویس",
"order": "ترتیب",
"content": "محتوا"
},
"review": {
"title": "عنوان",
"comment": "نظر",
"user": "کاربر",
"service": "سرویس",
"date": "تاریخ",
"status": "وضعیت",
"approved": "تایید شده",
"rejected": "رد شده",
"approve": "تایید",
"reject": "رد"
},
"header": {
"search": "جستجو"
},
"home": {
"new": "جدید",
"study": "مطالعه",
"myservice": "سرویس های من",
"active_service": "سرویس های فعال",
"ticket": "تیکت ها",
"unread_messages": "پیام خوانده نشده دارید ",
"announcement": "اطلاعیه ها",
"unread_announcement": "اطلاعیه خوانده نشده دارید",
"learnings": "آموزش ها",
"learning": "آموزش",
"access": "دسترسی سریع",
"add_access": "دسترسی سریع تازه ایجاد کنید.",
"danak_learning": "آموزش داناک",
"see_all": "دیدن همه",
"create_new_access": "ایجاد دسترسی سریع",
"services": "سرویس ها",
"service": "سرویس",
"customers": "مشتریان",
"customer": "مشتری",
"invoices": "صورت حساب ها",
"new_invoice": "صورت حساب جدید",
"ads": "تبلیغات",
"active_ads": "تبلیغ فعال"
},
"all": "همه",
"service": {
"my_service": "سرویس های من",
"search": "جستجو در سرویس ها",
"active_menu": "منو فعال",
"selected_danak": "انتخاب داناک",
"buy": "خرید",
"other_services": "سایر سرویس ها",
"year": "سالانه",
"month3": "۳ ماهه",
"month": "ماهیانه",
"company_develop": "شرکت توسعه دهنده",
"company_danak": "شرکت داناک",
"user_count": "تعداد کاربران",
"year_make": "سال ساخت",
"lang_app": "زبان نرم افزار",
"persian": "فارسی",
"score": "امتیاز",
"compelete_description": "توضیحات تکمیلی",
"submit_comment": "ثبت نظر",
"hint_comment": "شما پس از خرید این سرویس میتوانید نظر خود را به ثبت برسانید.",
"fullName": "نام و نام خانوادگی",
"enter_your_name": "نام خود را واردکنید",
"title": "عنوان",
"enter_title_comment": "عنوان نظر خود را وارد کنید",
"your_comment": "نظر شما",
"write_your_comment": "نظر خود را راجع به این سرویس بنویسید",
"users_comments": "نظرات کاربران",
"add_service": "افزودن سرویس",
"service_name": "نام سرویس",
"description_short": "توضیح کوتاه سرویس",
"company_developed": "شرکت توسعه دهنده",
"service_status": "وضعیت سرویس",
"special_danak": "این سرویس پیشنهاد ویژه داناک هست",
"service_link": "لینک دریافت سرویس",
"category": "دسته بندی",
"service_icon": "آیکون سرویس",
"upload_icon_service": "آیکون سرویس مورد نظر را آپلود کنید",
"service_images": "تصاویر سرویس",
"upload_images_service": "تصاویر سرویس مورد نظر را آپلود کنید",
"submit_service": "ثبت سرویس",
"new_service": "سرویس جدید",
"list_service": "لیست سرویس ها",
"status": "وضعیت",
"publish_date": "تاریخ انتشار",
"cateory_services": "دسته بندی سرویس ها",
"parent_category": "دسته مادر",
"count_sub_category": "تعداد زیر دسته",
"count_service": "تعداد سرویس",
"add_category": "اضافه کردن دسته بندی",
"status_category": "وضعیت دسته بندی",
"title_category": "عنوان دسته بندی",
"category_parent_2": "دسته بندی مادر",
"icon_serice_category": "آیکون سرویس مورد نظر را آپلود کنید",
"active": "فعال",
"deactive": "غیرفعال",
"software_langauge": "زبان نرم افزار",
"link": "لینک",
"danak_sujescet": "پیشنهاد داناک",
"plans": "پلن ها",
"add_plan": "افزودن پلن",
"active_plan": "پلن فعال",
"slider": "نمایش در اسلایدر",
"slug": "اسلاگ",
"page_title": "عنوان صفحه",
"page_description": "توضیحات صفحه"
},
"plan": {
"plans_list": "لیست پلن های ",
"new_plan": "پلن جدید",
"add_plan": "افزودن پلن",
"time": "مدت زمان",
"price": "قیمت",
"original_price": "قیمت اصلی",
"cancel": "لغو",
"submit_plan": "ثبت پلن",
"error_submit": "حداقل یک پلن باید اضافه شود",
"success_submit": "پلن با موفقیت ثبت شد",
"edit_plan": "ویرایش پلن"
},
"save": "ذخیره",
"search": "جستجو",
"upload": "آپلود",
"select": "انتخاب",
"footer": {
"home": "صفحه اصلی",
"customers": "مشتریان",
"foods": "غذا ها",
"announcements": "اطلاعیه ها",
"orders": "سفارشات"
},
"ticket": {
"tickets": "تیکت ها",
"new_ticket": "تیکت جدید",
"answered": "پاسخ داده شده",
"checking": "در حال بررسی",
"wait_answered": "در انتظار پاسخ",
"closed": "بسته شده",
"number": "شماره",
"title": "عنوان",
"team": "تیم",
"date": "تاریخ",
"status": "وضعیت",
"priority": "الویت",
"detail": "جزییات",
"submit_your_message": "ثبت پیام شما",
"name": "نام",
"email": "ایمیل",
"subject": "موضوع",
"enter_your_subject": "موضوع درخواست خودرا وارد کنید",
"select_your_service": "سرویس مربوطه",
"not_once": "هیچکدام",
"your_description": "توضیحات شما",
"attachment": "فایل های ضمیمه",
"send": "ارسال",
"title_hint": "جهت پاسخگویی بهتر به مسائل زیر توجه بفرمایید :",
"ticket_number": "تیکت شماره",
"user_name": "نام کاربر",
"expert": "کارشناس: ",
"answer": "پاسخ",
"send_answer": "ارسال پاسخ",
"close_ticket": "بستن تیکت",
"info_ticket": "اطلاعات تیکت",
"update_sms": "به روزرسانی این تیکت از طریق پیامک اطلاع داده میشود",
"category_ticket": "دسته بندی تیکت",
"users_count": "تعداد کاربر",
"add_category": "اضافه کردن دسته بندی",
"status_category": "وضعیت دسته بندی",
"title_category": "عنوان دسته بندی",
"users": "کاربران",
"MEDIUM": "متوسط",
"LOW": "پایین",
"HIGH": "بالا",
"PENDING": "در انتظار",
"ANSWERED": "پاسخ داده شده",
"CLOSED": "بسته شده",
"asignto": "کارشناس پاسخگویی",
"category": "دسته بندی",
"low": "پایین",
"medium": "متوسط",
"high": "بالا",
"service": "سرویس",
"refer_ticket": "ارجاع تیکت",
"user": "کاربر",
"assign_to": "کارشناس پاسخگویی",
"user_plan": "پلن کاربر",
"refer_ticket_success": "تیکت با موفقیت ارجاع شد",
"refer_ticket_error": "تیکت ارجاع نشد"
},
"active": "فعال",
"inactive": "غیرفعال",
"attach": "پیوست",
"date": "تاریخ",
"status": "وضعیت",
"title": "عنوان",
"ads": {
"ads": "تبلیغات",
"upload": "آپلود",
"deactive": "غیرفعال",
"submit": "ثبت تبلیغ",
"upload_picture": "تصویر نمایش تبلیغ",
"to_upload_message": "تصویر مورد نظر را آپلود کنید",
"new_ads": "افزودن تبلیغ",
"location": "جایگاه",
"startDate": "تاریخ شروع نمایش",
"endDate": "تاریخ پایان نمایش",
"search": "جستجو",
"number": "شماره",
"title": "عنوان",
"date": "تاریخ",
"status": "وضعیت",
"enter_your_title": "عنوان تبلیغ را وارد کنید",
"link": "لینک",
"enter_your_link": "لینک تبلیغ را وارد کنید",
"HOMEPAGE_TOP": "بالای صفحه اصلی",
"SINGLE_SERVICE_PAGE": "صفحه سرویس تکی",
"MY_SERVICES_PAGE": "صفحه سرویس های من",
"OTHER_SERVICES_TOP": "بالای سایر سرویس ها",
"OTHER_SERVICES_LEFT": "سمت چپ سایر سرویس ها",
"SERVICE": "سرویس",
"file_error": "فایل مورد نظر باید انتخاب شود",
"BLOG_PAGE_TOP_RIGHT": "بالای صفحه بلاگ راست",
"BLOG_PAGE_BOTTOM_RIGHT": "پایین صفحه بلاگ راست",
"BLOG_PAGE_TOP_LEFT": "بالای صفحه بلاگ چپ"
},
"discount": {
"list": "لیست تخفیفات",
"type": "نوع تخفیف",
"price": "مبلغ تخفیف",
"title": "عنوان کد",
"code": "کد تخفیف",
"CODE": "کد تخفیف",
"enter_discount_description": "عنوان کد تخفیف را وارد کنید",
"add_new_discount": "افزودن تخفیف",
"auto_generate_code": "ساخت کد",
"price_or_percent": "درصد / مبلغ تخفیف",
"enter_price_or_percent": "درصد / مبلغ تخفیف را وارد کنید",
"service": "سرویس",
"status": "وضعیت",
"number": "شماره",
"startDate": "تاریخ شروع",
"endDate": "تاریخ پایان",
"service_status": "وضعیت سرویس",
"availabe_service": "سرویس هایی که شامل تخفیف میشوند",
"new_discount": "کد تخفیف جدید",
"submit": "ثبت تخفیف",
"discount_code": "کد تخفیف",
"direct_discount": "تخفیف مستقیم",
"fixed_amount": "مبلغ ثابت",
"percent": "درصد",
"errors_plan": "سرویس و پلن را انتخاب کنید",
"DIRECT": "مستقیم",
"FIXED": "ثابت",
"plan": "پلن",
"user": "کاربر",
"0": "درصد",
"1": "مبلغ",
"update_discount": "ویرایش تخفیف",
"customer": "مشتری"
},
"blog": {
"blog": "بلاگ",
"category": "دسته بندی",
"new_blog": "افزودن بلاگ",
"blog_title": "عنوان بلاگ",
"blog_summary": "خلاصه بلاگ",
"content": "محتوا",
"blog_status": "وضعیت بلاگ",
"featured_image": "تصویر شاخص",
"tags": "برچسب ها",
"tag_hint": "جدا کردن با کاما یا کلید Enter ",
"shareDate": "تاریخ انتشار",
"enter_your_title": "عنوان بلاگ را وارد کنید",
"add_blog": "افزودن بلاگ",
"blog_category": "دسته بندی بلاگ",
"add_category": "اضافه کردن دسته بندی",
"category_status": "وضعیت دسته بندی",
"category_title": "عنوان دسته بندی",
"parent_category": "دسته بندی مادر",
"price": "مبلغ تخفیف",
"title": "عنوان کد",
"code": "کد تخفیف",
"enter_discount_description": "عنوان کد تخفیف را وارد کنید",
"add_new_discount": "افزودن تخفیف",
"auto_generate_code": "ساخت کد",
"price_or_percent": "درصد / مبلغ تخفیف",
"enter_price_or_percent": "درصد / مبلغ تخفیف را وارد کنید",
"service": "سرویس",
"status": "وضعیت",
"number": "شماره",
"startDate": "تاریخ شروع",
"endDate": "تاریخ پایان",
"service_status": "وضعیت سرویس",
"availabe_service": "سرویس هایی که شامل تخفیف میشوند",
"new_discount": "کد تخفیف جدید",
"submit": "ثبت بلاگ",
"discount_code": "کد تخفیف",
"direct_discount": "تخفیف مستقیم",
"fixed_amount": "مبلغ ثابت",
"percent": "درصد",
"enter_your_summary": "خلاصه بلاگ را وارد کنید",
"enter_your_content": "محتوای بلاگ را وارد کنید",
"blog_content": "محتوای بلاگ",
"enter_tags": "برچسب ها را وارد کنید",
"enter_your_slug": "اسلاگ بلاگ را وارد کنید",
"slug": "اسلاگ",
"meta_title": "عنوان متا",
"enter_your_meta_title": "عنوان متا بلاگ را وارد کنید",
"meta_description": "توضیحات متا",
"enter_your_meta_description": "توضیحات متا بلاگ را وارد کنید",
"comments": "نظرات",
"user": "کاربر",
"comment": "نظر",
"picture": "تصویر"
},
"receip": {
"receips": "صورتحساب ها",
"receip_list": "لیست صورتحساب ها",
"add_receip": "افزودن صورتحساب",
"dont_pay": "پرداخت نشده",
"dont_confrim": "تایید نشده",
"paid": "پرداخت شده",
"canceled": "لغو شده",
"returned": "پس داده شده",
"its_time": "سررسیده شده",
"archive": "بایگانی شده",
"detail_account": "جزییات صورتحساب ",
"personal_information": "اطلاعات شخصی",
"type_person": "نوع شخص : ",
"company": "حقوقی",
"representativeـname": "نام نماینده :",
"company_name": "نام شرکت/ سازمان :",
"registration_number": "شماره ثبت :",
"tel_company": "تلفن شرکت :",
"national_code": "کد ملی نماینده :",
"postal_code": "کد پستی :",
"state": "استان :",
"city": "شهر :",
"address": "آدرس :",
"detail_receip": "مشخصات صورتحساب",
"not_paid": "پرداخت نشده",
"description": "توضیحات",
"price": "مبلغ",
"receip_information": "اطلاعات صورتحساب",
"date_factor": "تاریخ صدور فاکتور",
"due_date": "تاریخ سررسید فاکتور",
"factor_number": "شماره فاکتور",
"status_factor": "وضعیت فاکتور",
"submit_receip": "ثبت صورتحساب",
"customer_information": "اطلاعات مشتری",
"customer": "مشتری",
"receipt_information": "اطلاعات صورتحساب",
"number": "شماره",
"product_name": "نام کالا",
"count": "تعداد",
"unit_amount": "مبلغ واحد",
"discount": "تخفیف(٪)",
"total_amount": "مبلغ کل(تومان)",
"tax": "مالیات بر ارزش افزوده (۱۰ درصد)",
"total": "جمع",
"error_empty": "همه فیلد ها را پر کنید",
"date_receipt": "تاریخ صورتحساب",
"last_date_receipt": "آخرین مهلت پرداخت",
"service": "سرویس",
"status": "وضعیت تایید",
"status_paid": "وضعیت پرداخت",
"PENDING": "در انتظار",
"PAID": "پرداخت شده",
"CANCELED": "لغو شده",
"EXPIRED": "منقضی شده",
"approved": "تایید شده",
"APPROVED": "تایید شده",
"ARCHIVED": "بایگانی شده",
"pending": "در انتظار",
"since": "از تاریخ",
"to": "تا تاریخ",
"recurring": "دوره ای",
"select_type": "انتخاب نوع دوره",
"1": "هفتگی",
"2": "ماهانه",
"3": "سه‌ماهه",
"4": "شش‌ماهه",
"5": "سالانه",
"maxRecurringCycles": "حداکثر تعداد تکرار دوره",
"WAIT_PAYMENT": "در انتظار پرداخت",
"overdue": "سررسید شده",
"OVERDUE": "سررسید شده",
"lateFee": "مبلغ جریمه",
"recurringPeriod": "دوره تکرار",
"WEEKLY": "هفتگی",
"MONTHLY": "ماهانه",
"QUARTERLY": "سه‌ماهه",
"SEMIANNUALLY": "شش‌ماهه",
"ANNUALLY": "سالانه"
},
"edit": "ویرایش",
"transaction": {
"transaction": "تراکنش ها",
"totao_deposit": "مجموع واریز ها",
"total_withdrawal": "مجموع برداشت ها",
"balance": "موجودی کیف پول",
"transaction2": "تراکنش",
"type_deposit": "نوع واریز",
"status": "وضعیت",
"success_paid": "پرداخت موفق",
"print": "پرینت",
"download": "دانلود",
"type_getway": "نوع درگاه",
"doer": "انجام دهنده",
"tracking_number": "شماره پیگیری",
"description_transaction": "شرح تراکنش",
"amount": "مبلغ",
"date": "تاریخ تراکنش",
"order_number": "شماره سفارش",
"description": "توضیحات تراکنش",
"user_search": "جستجو کاربر"
},
"announcement": {
"announcements": "اطلاعیه ها",
"search": "جستجو در اطلاعیه ها",
"new": "جدید",
"important": "مهم",
"text_announcement": "متن اطلاعیه",
"add_announcement": "افزودن اطلاعیه",
"submit_announcement": "ثبت اطلاعیه",
"title": "عنوان اطلاعیه",
"text": "متن اطلاعیه",
"publish_date": "زمان انتشار",
"info_publishdata": "اگر مایلید اطلاعیه در زمان دیگری منتشر شود تاریخ انتشار مورد نظر را انتخاب کنید",
"label_importance": "لیبل “مهم” در اطلاعیه درج شود",
"select_service": "انتخاب سرویس",
"select_customer": "انتخاب مشتری",
"contact_announcement": "مخاطب این اطلاعیه",
"date_created": "تاریخ ثبت",
"summary": "خلاصه اطلاعیه",
"user": "کاربر مخاطب",
"created_date": "زمان ایجاد",
"service": "سرویس"
},
"back": "بازگشت",
"criticisms": {
"criticisms": "انتقادات",
"submit_your_criticisms": "ثبت انتقاد شما",
"description_criticisms": "با انتقاد خود راهگشای ما باشید.",
"subject": "موضوع",
"enter_your_subject": "موضوع انتقاد خودرا وارد کنید",
"description": "توضیحات",
"enter_description": "توضیحات شما",
"criticisms_list": "لیست انتقادات",
"number": "شماره",
"title_criticisms": "عنوان انتقاد",
"summary_criticisms": "خلاصه انتقاد",
"date_created": "تاریخ ثبت",
"criticism": "انتقاد",
"documents": "مستندات"
},
"detail": "جزییات",
"select_file": "انتخاب فایل",
"no_select_file": "هیچ فایلی انتخاب نشده است",
"attachment": "فایل های ضمیمه",
"learning": {
"learning": "آموزش",
"learnings": "آموزش ها",
"new_learning": "افزودن آموزش",
"category_learning": "دسته بندی آموزش",
"submit_learning": "ثبت آموزش",
"title": "عنوان آموزش",
"category": "دسته بندی",
"description": "توضیحات",
"video_duration": "مدت زمان ویدیو",
"learning_cover": "کاور آموزش",
"upload_cover": "آپلود کاور",
"video": "آپلود ویدیو",
"upload_video": "آپلود ویدیو",
"error_video_cover_required": "آپلود ویدیو و کاور اجباری است",
"list_learning": "لیست آموزش ها"
},
"setting": {
"setting": "تنظیمات",
"notification": "پیام و ایمیل",
"password": "رمز عبور",
"setting_email_message": "تنظیمات ارسال پیام و ایمیل",
"notification_announcement": "اعلانات و اطلاعیه ها",
"email": "ایمیل",
"sms": "پیامک",
"change_password": "تغییر رمز عبور",
"current_password": "رمز عبور فعلی",
"new_password": "رمز عبور جدید",
"reapeat_password": "تکرار رمز عبور",
"save_change": "ذخیره تغییرات",
"password_is": "رمز عبور می‌بایست:",
"min-8": "حداقل 8 کاراکتر باشد",
"mix_charachter_number": "ترکیبی از حروف کوچک و بزرگ باشد",
"exsist_number": "شامل اعداد باشد",
"exsist_special_character": "شامل کاراکتر های خاص (نماد ها) باشد"
},
"wallet": {
"increese_wallet": "افزایش موجودی حساب اعتباری",
"online_pay": "پرداخت آنلاین",
"card_to_card": "کارت به کارت",
"select_price": "انتخاب مبلغ",
"optional_price": "مبلغ دلخواه‌ (به تومان وارد کنید)",
"enter_your_price": "مبلغ دلخواه خودرا وارد کنید",
"select_payment": "انتخاب درگاه پرداخت",
"pay": "پرداخت",
"amount": "مبلغ",
"enter_amount_card": "مبلغ کارت به کارت شده را وارد کنید",
"upload_deposit_slip": "آپلود فیش واریزی",
"submit_slip": "ثبت فیش",
"sheba": "شبا",
"pay_sheba": "پرداخت شبا",
"enter_sheba_deposit": "مبلغ شبا شده شده را وارد کنید"
},
"notif": {
"natification": "اعلان ها",
"all_read": "همه خوانده شده"
},
"user": {
"users_list": "لیست کاربران",
"add_user": "افزودن کاربر",
"name": "نام",
"title_job": "عنوان شغلی",
"email": "ایمیل",
"role": "نقش",
"submit_user": "ثبت کاربر",
"info_user": "اطلاعات کاربر",
"family": "نام خانوادگی",
"phone_number": "شماره تماس",
"password": "رمز عبور",
"reapeat_password": "تکرار رمز عبور",
"image": "تصویر حساب کاربری",
"roles_list": "لیست نقش ها",
"add_role": "افزودن نقش",
"role_search": "جستجو در نقش ها",
"title": "عنوان",
"user": "کاربر",
"submit_role": "ثبت نقش",
"title_role": "عنوان نقش",
"permissions": "دسترسی ها",
"status_role": "وضعیت نقش",
"error_count_permission": "حداقل به دسترسی رو باید انتخاب کنید",
"is_admin": "آیا ادمین است؟",
"user_group": "گروه کاربری",
"add_user_group": "ساخت گروه کاربری",
"users": "کاربران",
"group_add": "ساخت گروه",
"submit_group": "ثبت گروه",
"error_count_users": "حداقل یک کاربر باید انتخاب شود",
"list_user_group": "لیست گروه های کاربری",
"groups": "گروه ها",
"group_name": "نام گروه",
"update_user": "ویرایش کاربر"
},
"submit": "ثبت",
"yes": "بله",
"no": "خیر",
"profile": {
"account_user": "حساب کاربری",
"image_profile": "تصویر حساب کاربری",
"image_profile_desc": "تصویر خود را در حساب کاربری قرار دهید.",
"upload_image": "آپلود تصویر",
"formats": "فرمت های",
"max_size": "حداکثر ۱مگابایت",
"format_image": " jpg, png,jpeg.",
"info_account": "اطلاعات حساب",
"auth_after_change_info": "در صورت تغییر اطلاعات حساب باید مجددا احراز هویت انجام شود.",
"username": "نام کاربری",
"phone_call": "شماره تماس",
"personal_information": "اطلاعات شخصی",
"enter_carefully_your_information": "اطلاعت شخصی خود را با دقت وارد کنید.",
"date_of_birth": "تاریخ تولد",
"national_code": "کد ملی",
"address": "آدرس",
"address_live": "آدرس محل سکونت خود را با دقت وارد کنید",
"province": "استان",
"city": "شهر",
"postal_code": "کد پستی",
"username_available": "نام کاربری موجود",
"username_not_available": "نام کاربری موجود نیست",
"username_save_success": "نام کاربری با موفقیت ذخیره شد",
"image_uploaded_successfully": "تصویر با موفقیت آپلود شد",
"confrim_email": "تایید ایمیل",
"confrim": "تایید صورتحساب ",
"link_email": "یک لینک برای تایید ایمیل برای شما ارسال شد.",
"email_not_verified": "ایمیل تایید نشده"
},
"email": "ایمیل",
"customer": {
"customer_list": "لیست مشتریان",
"add_cutomer": "افزودن مشتری",
"name": "نام",
"company": "سازمان / شرکت",
"email": "ایمیل",
"active_services": "سرویس های فعال",
"factor": "صورت حساب ها",
"tickets": "تیکت ها",
"detail": "جزییات",
"submit_customer": "ثبت مشتری",
"family": "نام خانوادگی",
"dateofbirth": "تاریخ تولد",
"national_code": "کد ملی",
"phone_number": "شماره تماس",
"information_legal": "اطلاعات حقوقی",
"economic_code": "کد اقتصادی",
"id_national": "شناسه ملی",
"enter_nation_code": "کد ملی خود را وارد کنید",
"enter_email": "ایمیل خود را وارد کنید",
"enter_phone_number": "شماره تماس خود را وارد کنید",
"enter_economic_code": "کد اقتصادی را وارد کنید",
"enter_id_nation": "شناسه ملی خود را وارد کنید",
"registeration_id": "شناسه ثبت",
"enter_registeration_id": "شناسه ثبت خود را وارد کنید",
"your_phone_fixed": "شماره تماس ثابت",
"enter_phone_fixed": "شماره تماس خود را وارد کنید",
"state": "استان",
"city": "شهر",
"password": "رمز عبور",
"reapeat_password": "تکرار رمز عبور",
"copany_name": "نام شرکت",
"enter_company_name": "نام ثبتی شرکت را وارد کنید",
"postal_code": "کد پستی",
"enter_postal_code": "کد پستی را وارد کنید",
"address": "آدرس",
"image_profile": "تصویر پروفایل",
"upload_image": "آپلود تصویر",
"new_password": "رمز عبور جدید"
},
"priority": "الویت",
"messages": {
"messages_list": "لیست پیام ها",
"submit_date": "تاریخ ثبت",
"number": "شماره",
"fullName": "نام و نام خانوادگی",
"email": "ایمیل",
"subject": "موضوع",
"date": "تاریخ",
"message": "پیام"
},
"loading": "در حال بارگذاری...",
"success": "عملیات با موفقیت انجام شد.",
"confrim": {
"subject": "حذف",
"content": "برای حذف این آیتم مطمئن هستید؟",
"yes": "بله",
"cancel": "لغو"
},
"cardBank": {
"new_account": "حساب جدید",
"submit_account": "ثبت حساب",
"account_information": "اطلاعات حساب",
"status_account": "وضعیت حساب",
"account_owner_name": "نام صاحب حساب",
"bank_name": "نام بانک",
"card_number": "شماره کارت",
"sheba": "شماره شبا",
"manage_banks": "مدیریت حساب های بانکی",
"add_account": "افزودن حساب"
},
"payment": {
"Online": "آنلاین",
"Cash": "نقدی",
"CardOnDelivery": "کارت هنگام تحویل",
"Wallet": "کیف پول",
"zarinpal": "زرین‌پال",
"payments": "پرداخت ها",
"status_payment": "وضعیت پرداخت",
"user": "کاربر",
"card_number": "شماره کارت",
"sheba_number": "شماره شبا",
"amount": "مبلغ",
"date": "تاریخ",
"payment_type": "نوع پرداخت",
"PENDING": "در انتظار",
"FAILD": "ناموفق",
"COMPELETED": "تکمیل شده",
"CANCELED": "لغو شده",
"REJECTED": "رد شده",
"APPROVED": "تایید شده",
"aceept": "تایید",
"reject": "رد",
"accept_confrim": "آیا از تایید این پرداخت مطمئن هستید؟",
"reject_comment_error": "لطفا دلیل رد پرداخت را وارد کنید",
"receip_image": "تصویر فیش واریزی"
},
"support": {
"support_plan": "پلن پشتیبانی",
"support_plan_list": "لیست پلن پشتیبانی",
"support_plan_create": "ایجاد پلن پشتیبانی",
"support_plan_edit": "ویرایش پلن پشتیبانی",
"1-learning_docs": "راهنما و مستندات آموزشی",
"2-ticket_support": "ارسال تیکت",
"3-ticket_limit": "ظرفیت ارسال تیکت",
"4-response_time": "زمان پاسخگویی به تیکت",
"5-technical_issue_resolution": "رفع ایراد فنی",
"6-phone_support": "پاسخگویی تلفنی",
"7-technical_expert_access": "دسترسی به متخصصین فنی",
"8-backup_version": "نسخه بکاپ",
"9-service_functionality_test": "تست عملکرد سرویس",
"10-on_site_support": "پشتیبانی در محل",
"11-on_site_training": "آموزش در محل",
"plans": "پلن ها",
"plan_name": "نام پلن",
"price": "قیمت",
"duration": "مدت",
"features": "ویژگی ها",
"actions": "عملیات",
"new_plan": "پلن جدید",
"plan_users": "کاربران ",
"ACTIVE": "فعال",
"INACTIVE": "غیرفعال",
"EXPIRED": "منقضی شده",
"start_date": "تاریخ شروع",
"end_date": "تاریخ پایان",
"status": "وضعیت",
"full_name": "نام و نام خانوادگی",
"users": "کاربران"
},
"schedule": {
"multiple_shifts_hint": "برای یک روز میتوانید چند شیفت زمانی ثبت کنید مثلا 8 تا 12 و 14 تا 18",
"attention": "دقت کنید",
"order_time_limit": "فقط در زمان های فوق امکان سفارش گیری هست",
"weekDay": {
"0": "شنبه",
"1": "یکشنبه",
"2": "دوشنبه",
"3": "سه‌شنبه",
"4": "چهارشنبه",
"5": "پنج‌شنبه",
"6": "جمعه"
}
},
"payment_methods": {
"payment_methods": "روش های پرداخت",
"payment_methods_list": "لیست روش های پرداخت",
"payment_methods_add": "افزودن روش پرداخت",
"payment_methods_update": "ویرایش روش پرداخت"
},
"order_status": {
"pendingPayment": "در انتظار پرداخت",
"paid": "پرداخت شده",
"confirmed": "تایید شده",
"preparing": "در حال آماده سازی",
"ready": "آماده",
"shipped": "ارسال شده",
"completed": "تکمیل شده",
"canceled": "لغو شده",
"deliveredToWaiter": "تحویل به گارسون",
"deliveredToReceptionist": "تحویل به پیشخوان"
},
"reviews": {
"reviews": "نظرات",
"reviews_list": "لیست نظرات",
"add_review": "افزودن نظر",
"edit_review": "ویرایش نظر",
"review_list": "لیست نظرات",
"review_name": "نام نظر",
"review_email": "ایمیل نظر",
"review_comment": "نظر",
"great_taste": "طعم عالی",
"fast_delivery": "تحویل سریع",
"good_quality": "کیفیت خوب",
"good_portion_size": "اندازه مناسب پرس",
"friendly_service": "خدمات دوستانه",
"small_portion": "پرس کوچک",
"slow_delivery": "تحویل کند",
"poor_quality": "کیفیت پایین",
"overpriced": "قیمت بالا",
"unfriendly_service": "خدمات نامناسب"
},
"pager": {
"status": {
"pending": "در انتظار",
"acknowledged": "تایید شده",
"rejected": "رد شده",
"completed": "تکمیل شده"
},
"change_status": "تغییر وضعیت",
"save": "ذخیره"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+119
View File
@@ -0,0 +1,119 @@
import { type FC } from 'react'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { useNavigate } from 'react-router-dom'
import Button from '@/components/Button'
import Input from '@/components/Input'
import Select from '@/components/Select'
import { useCreateAdmin } from './hooks/useAdminData'
import { useGetRoles } from '@/pages/roles/hooks/useRolesData'
import type { CreateAdminType } from './types/Types'
import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
import { extractErrorMessage } from '@/config/func'
const CreateAdmin: FC = () => {
const navigate = useNavigate()
const { mutate: createAdmin, isPending } = useCreateAdmin()
const { data: rolesData } = useGetRoles()
const roles = rolesData?.data?.map(role => ({
label: role.name,
value: role.id
})) || []
const formik = useFormik<CreateAdminType>({
initialValues: {
phone: '',
firstName: '',
lastName: '',
roleId: '',
},
validationSchema: Yup.object().shape({
phone: Yup.string().required('شماره تلفن الزامی است'),
firstName: Yup.string().required('نام الزامی است'),
lastName: Yup.string().required('نام خانوادگی الزامی است'),
roleId: Yup.string().required('نقش الزامی است'),
}),
onSubmit: (values) => {
createAdmin(values, {
onSuccess: () => {
toast.success('مدیر با موفقیت ایجاد شد')
navigate(Pages.admins.list)
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error))
},
})
},
})
return (
<div className='mt-5'>
<div className='flex w-full justify-between items-center'>
<div className='text-lg font-light'>
مدیر جدید
</div>
<div>
<Button
className='px-5'
onClick={() => formik.handleSubmit()}
isloading={isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle className='size-5' color='white' />
<div>ثبت مدیر</div>
</div>
</Button>
</div>
</div>
<div className='mt-6'>
<div className='bg-white rounded-3xl p-6'>
<div className='mb-4'>
<Input
label='نام'
name='firstName'
value={formik.values.firstName}
onChange={formik.handleChange}
error_text={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : ''}
/>
</div>
<div className='mt-6'>
<Input
label='نام خانوادگی'
name='lastName'
value={formik.values.lastName}
onChange={formik.handleChange}
error_text={formik.touched.lastName && formik.errors.lastName ? formik.errors.lastName : ''}
/>
</div>
<div className='mt-6'>
<Input
label='شماره تلفن'
name='phone'
value={formik.values.phone}
onChange={formik.handleChange}
error_text={formik.touched.phone && formik.errors.phone ? formik.errors.phone : ''}
/>
</div>
<div className='mt-6'>
<Select
label='نقش'
name='roleId'
value={formik.values.roleId}
onChange={formik.handleChange}
items={roles}
placeholder='نقش را انتخاب کنید'
error_text={formik.touched.roleId && formik.errors.roleId ? formik.errors.roleId : ''}
/>
</div>
</div>
</div>
</div>
)
}
export default CreateAdmin
+74
View File
@@ -0,0 +1,74 @@
import { type FC } from 'react'
import Table from '@/components/Table'
import Filters from '@/components/Filters'
import { useGetAdmins, useDeleteAdmin } from './hooks/useAdminData'
import { useAdminFilters } from './hooks/useAdminFilters'
import { getAdminTableColumns } from './components/AdminTableColumns'
import { useAdminFiltersFields } from './components/AdminFiltersFields'
import { Add } from 'iconsax-react'
import { Link } from 'react-router-dom'
import { Pages } from '@/config/Pages'
import Button from '@/components/Button'
const AdminList: FC = () => {
const {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
} = useAdminFilters()
const { data: adminsData, isLoading } = useGetAdmins(apiParams)
const { mutate: deleteAdmin, isPending: isDeleting } = useDeleteAdmin()
const admins = adminsData?.data || []
const columns = getAdminTableColumns({
onDelete: (id: string) => deleteAdmin(id),
isDeleting
})
const filterFields = useAdminFiltersFields()
const totalPages = Math.ceil(admins.length / limit) || 1
return (
<div className='mt-5'>
<div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>لیست مدیران</h1>
<Link
to={Pages.admins.add}
>
<Button className='w-fit px-6'>
<div className='flex gap-2 items-center'>
<Add color='#fff' size={20} />
<span>افزودن مدیر</span>
</div>
</Button>
</Link>
</div>
<div className='mt-8'>
<Filters
fields={filterFields}
onChange={handleFiltersChange}
initialValues={filters}
searchField="search"
/>
</div>
<Table
columns={columns}
data={admins}
isloading={isLoading}
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange,
}}
/>
</div>
)
}
export default AdminList
+156
View File
@@ -0,0 +1,156 @@
import { type FC, useEffect } from 'react'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { useNavigate, useParams } from 'react-router-dom'
import Button from '@/components/Button'
import Input from '@/components/Input'
import Select from '@/components/Select'
import { useGetAdminById, useUpdateAdmin } from './hooks/useAdminData'
import { useGetRoles } from '@/pages/roles/hooks/useRolesData'
import type { CreateAdminType } from './types/Types'
import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
import { extractErrorMessage } from '@/config/func'
const UpdateAdmin: FC = () => {
const navigate = useNavigate()
const { id } = useParams()
const { data: adminData, isLoading } = useGetAdminById(id!)
const { mutate: updateAdmin, isPending } = useUpdateAdmin()
const { data: rolesData } = useGetRoles()
const roles = rolesData?.data?.map(role => ({
label: role.name,
value: role.id
})) || []
const admin = adminData?.data
const formik = useFormik<CreateAdminType>({
initialValues: {
phone: '',
firstName: '',
lastName: '',
roleId: '',
},
validationSchema: Yup.object().shape({
phone: Yup.string().required('شماره تلفن الزامی است'),
firstName: Yup.string().required('نام الزامی است'),
lastName: Yup.string().required('نام خانوادگی الزامی است'),
roleId: Yup.string().required('نقش الزامی است'),
}),
onSubmit: (values) => {
if (!id) {
toast.error('شناسه مدیر یافت نشد')
return
}
updateAdmin({ id, params: values }, {
onSuccess: () => {
toast.success('مدیر با موفقیت به‌روزرسانی شد')
navigate(Pages.admins.list)
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error))
},
})
},
})
useEffect(() => {
if (admin) {
formik.setValues({
phone: admin.phone || '',
firstName: admin.firstName || '',
lastName: admin.lastName || '',
roleId: admin.roles?.[0]?.role?.id || '',
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [admin])
if (isLoading) {
return (
<div className='w-full mt-4 flex justify-center items-center'>
<div>در حال بارگذاری...</div>
</div>
)
}
if (!admin) {
return (
<div className='w-full mt-4 flex justify-center items-center'>
<div>مدیر یافت نشد</div>
</div>
)
}
return (
<div className='mt-5'>
<div className='flex w-full justify-between items-center'>
<div className='text-lg font-light'>
ویرایش مدیر
</div>
<div>
<Button
className='px-5'
onClick={() => formik.handleSubmit()}
isloading={isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle className='size-5' color='white' />
<div>ذخیره تغییرات</div>
</div>
</Button>
</div>
</div>
<div className='mt-6'>
<div className='bg-white rounded-3xl p-6'>
<div className='mb-4'>
<Input
label='نام'
name='firstName'
value={formik.values.firstName}
onChange={formik.handleChange}
error_text={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : ''}
/>
</div>
<div className='mt-6'>
<Input
label='نام خانوادگی'
name='lastName'
value={formik.values.lastName}
onChange={formik.handleChange}
error_text={formik.touched.lastName && formik.errors.lastName ? formik.errors.lastName : ''}
/>
</div>
<div className='mt-6'>
<Input
label='شماره تلفن'
name='phone'
value={formik.values.phone}
onChange={formik.handleChange}
error_text={formik.touched.phone && formik.errors.phone ? formik.errors.phone : ''}
/>
</div>
<div className='mt-6'>
<Select
label='نقش'
name='roleId'
value={formik.values.roleId}
onChange={formik.handleChange}
items={roles}
placeholder='نقش را انتخاب کنید'
error_text={formik.touched.roleId && formik.errors.roleId ? formik.errors.roleId : ''}
/>
</div>
</div>
</div>
</div>
)
}
export default UpdateAdmin
@@ -0,0 +1,13 @@
import { useMemo } from 'react'
import type { FieldType } from '@/components/Filters'
export const useAdminFiltersFields = (): FieldType[] => {
return useMemo(() => [
{
type: 'input',
name: 'search',
placeholder: 'جستجو',
},
], [])
}
@@ -0,0 +1,76 @@
import type { ColumnType } from '@/components/types/TableTypes'
import type { Admin } from '../types/Types'
import TrashWithConfrim from '@/components/TrashWithConfrim'
import { Eye } from 'iconsax-react'
import { Link } from 'react-router-dom'
import { Pages } from '@/config/Pages'
import { formatOptionalDate } from '@/helpers/func'
interface GetAdminTableColumnsParams {
onDelete?: (id: string) => void
isDeleting?: boolean
}
export const getAdminTableColumns = ({ onDelete, isDeleting }: GetAdminTableColumnsParams = {}): ColumnType<Admin>[] => {
return [
{
key: 'firstName',
title: 'نام',
render: (item: Admin) => {
return item.firstName || '-'
}
},
{
key: 'lastName',
title: 'نام خانوادگی',
render: (item: Admin) => {
return item.lastName || '-'
}
},
{
key: 'phone',
title: 'شماره تماس',
render: (item: Admin) => {
return item.phone || '-'
}
},
{
key: 'roles',
title: 'تعداد نقش‌ها',
render: (item: Admin) => {
return item.roles?.length || 0
}
},
{
key: 'createdAt',
title: 'تاریخ ایجاد',
render: (item: Admin) => {
return formatOptionalDate(item.createdAt)
}
},
{
key: 'actions',
title: '',
render: (item: Admin) => {
return (
<div className='flex gap-2 items-center'>
<Link to={Pages.admins.update + item.id}>
<Eye
size={20}
color='#8C90A3'
className='cursor-pointer'
/>
</Link>
{onDelete && (
<TrashWithConfrim
onDelete={() => onDelete(item.id)}
isloading={isDeleting}
/>
)}
</div>
)
}
},
]
}
+58
View File
@@ -0,0 +1,58 @@
import * as api from "../service/AdminService";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { GetAdminsParams, CreateAdminType } from "../types/Types";
export const useGetAdmins = (params?: GetAdminsParams) => {
return useQuery({
queryKey: ["admins", params],
queryFn: () => api.getAdmins(params),
});
};
export const useDeleteAdmin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteAdmin,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["admins"],
});
},
});
};
export const useCreateAdmin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createAdmin,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["admins"],
});
},
});
};
export const useUpdateAdmin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, params }: { id: string; params: CreateAdminType }) =>
api.updateAdmin(id, params),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({
queryKey: ["admins"],
});
queryClient.invalidateQueries({
queryKey: ["admins", id],
});
},
});
};
export const useGetAdminById = (id: string) => {
return useQuery({
queryKey: ["admin", id],
queryFn: () => api.getAdminById(id),
enabled: !!id,
});
};
+43
View File
@@ -0,0 +1,43 @@
import { useState, useMemo } from "react";
import type { FilterValues } from "@/components/Filters";
import type { GetAdminsParams } from "../types/Types";
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
export const useAdminFilters = () => {
const [filters, setFilters] = useState<FilterValues>({});
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
const [limit] = useState(DEFAULT_LIMIT);
const apiParams = useMemo<GetAdminsParams>(() => {
const params: GetAdminsParams = {
page: currentPage,
limit: limit,
};
if (filters.search) {
params.search = filters.search as string;
}
return params;
}, [filters, currentPage, limit]);
const handleFiltersChange = (newFilters: FilterValues) => {
setFilters(newFilters);
setCurrentPage(DEFAULT_PAGE);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
};
};
+40
View File
@@ -0,0 +1,40 @@
import axios from "@/config/axios";
import type {
GetAdminsResponse,
GetAdminsParams,
CreateAdminType,
GetAdminByIdResponse,
} from "../types/Types";
export const getAdmins = async (
params?: GetAdminsParams
): Promise<GetAdminsResponse> => {
const { data } = await axios.get<GetAdminsResponse>("/admin/admins", {
params,
});
return data;
};
export const deleteAdmin = async (id: string) => {
const { data } = await axios.delete(`/admin/admins/${id}`);
return data;
};
export const createAdmin = async (params: CreateAdminType) => {
const { data } = await axios.post("/admin/admins", params);
return data;
};
export const updateAdmin = async (id: string, params: CreateAdminType) => {
const { data } = await axios.patch(`/admin/admins/${id}`, params);
return data;
};
export const getAdminById = async (
adminId: string
): Promise<GetAdminByIdResponse> => {
const { data } = await axios.get<GetAdminByIdResponse>(
`/admin/admins/${adminId}`
);
return data;
};
+48
View File
@@ -0,0 +1,48 @@
import type { IResponse } from "@/types/response.types";
export type RoleDetail = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
restaurant: string | null;
};
export type AdminRole = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
admin: string;
role: RoleDetail;
restaurant: string;
};
export type Admin = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
firstName: string;
lastName: string;
phone: string;
roles: AdminRole[];
};
export type GetAdminsParams = {
page?: number;
limit?: number;
search?: string;
};
export type GetAdminsResponse = IResponse<Admin[]>;
export type GetAdminByIdResponse = IResponse<Admin>;
export type CreateAdminType = {
phone: string;
firstName: string;
lastName: string;
roleId: string;
};

Some files were not shown because too many files have changed in this diff Show More