admin panel

This commit is contained in:
hamid zarghami
2025-07-08 12:43:22 +03:30
parent fed1933ec4
commit e019641d59
110 changed files with 13274 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
VITE_TOKEN_NAME = 'dsc_token'
VITE_REFRESH_TOKEN_NAME = 'dsc_refresh_token'
VITE_DANAK_BASE_URL ='https://api.danakcorp.com'
VITE_BASE_URL = 'http://192.168.1.134:4000'
# VITE_BASE_URL = 'https://dmail-api.danakcorp.com'
VITE_SERVICE_ID = 'e51afdc3-ea0b-49cf-8f49-2a6f131b024e'
+48
View File
@@ -0,0 +1,48 @@
name: deploy to danak
on:
push:
branches:
- master
jobs:
build_and_deploy:
runs-on: ubuntu-latest
env:
DANAK_SERVER: "https://captain.dev.danakcorp.com"
APP_TOKEN: deb2000a493e5291aed0e2f9d8791b413f6e2f2e6996e8633223fc40a35af15c
APP_NAME: dmail-front
GITHUB_TOKEN: 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
steps:
- name: Check out repositorys
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: zmihamid
password: ${{ env.GITHUB_TOKEN }}
- name: Preset Image Name
run: echo "IMAGE_URL=$(echo ghcr.io/zmihamid/${{ github.event.repository.name }}:$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Build and push Docker Image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ env.IMAGE_URL }}
- name: Install CapRover CLI
run: npm install -g caprover
- name: deploy to server
run: |
caprover deploy -a $APP_NAME -u $DANAK_SERVER --appToken $APP_TOKEN -i "$IMAGE_URL"
+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?
+26
View File
@@ -0,0 +1,26 @@
FROM node:22-alpine AS builder
# Install tzdata to support timezone settings
RUN apk add --no-cache tzdata
RUN npm install -g corepack@latest
RUN corepack enable && corepack prepare pnpm@10 --activate
# 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 pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --loglevel info
COPY . ./
RUN pnpm 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;"]
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+29
View File
@@ -0,0 +1,29 @@
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'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</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]\.";
}
+71
View File
@@ -0,0 +1,71 @@
{
"name": "dmail-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"orval": "orval"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-switch": "^1.2.5",
"@react-three/drei": "^10.1.2",
"@react-three/fiber": "^9.1.2",
"@tailwindcss/vite": "^4.1.4",
"@tanstack/react-query": "^5.81.5",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.0.1",
"iconsax-react": "^0.0.8",
"js-cookie": "^3.0.5",
"lucide-react": "^0.511.0",
"moment-jalaali": "^0.10.4",
"quill": "^2.0.3",
"react": "^19.0.0",
"react-colorful": "^5.6.1",
"react-date-object": "^2.1.9",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.60.0",
"react-i18next": "^15.5.1",
"react-loading-skeleton": "^3.5.0",
"react-multi-date-picker": "^4.5.2",
"react-quill": "^2.0.0",
"react-quill-new": "^3.4.6",
"react-router-dom": "^7.5.2",
"react-spinners": "^0.17.0",
"swiper": "^11.2.6",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4",
"three": "^0.177.0",
"uuid": "^11.1.0",
"vite-tsconfig-paths": "^5.1.4",
"yup": "^1.6.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/js-cookie": "^3.0.6",
"@types/moment-jalaali": "^0.7.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/three": "^0.177.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"orval": "^7.10.0",
"tw-animate-css": "^1.3.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1"
},
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
}
+6277
View File
File diff suppressed because it is too large Load Diff
+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

+111
View File
@@ -0,0 +1,111 @@
import { FC } from 'react'
import './assets/fonts/irancell/style.css'
import 'react-quill-new/dist/quill.snow.css';
import 'react-loading-skeleton/dist/skeleton.css'
import 'swiper/swiper-bundle.css';
import { BrowserRouter } from 'react-router-dom'
import Main from './shared/Main'
import i18next from 'i18next'
import FaJson from './langs/fa.json'
import { I18nextProvider } from 'react-i18next'
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { IApiErrorRepsonse } from './types/error.types';
import ToastContainer from './components/Toast';
import { getRefreshToken, removeRefreshToken, removeToken, setRefreshToken, setToken } from './config/func';
import { refreshToken } from './pages/auth/service/Service';
declare global {
interface Window {
isRefreshingToken?: boolean;
}
}
i18next.init({
interpolation: { escapeValue: false },
lng: 'fa',
defaultNS: 'global',
resources: {
fa: {
global: FaJson
}
}
})
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: async (error: IApiErrorRepsonse) => {
if (error?.response?.status === 401) {
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();
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);
}
// 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);
// Clear tokens and redirect to login
await removeToken();
await removeRefreshToken();
window.location.href = '/auth/login';
}
}
}
}),
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false
// staleTime: 86400000 // 1 day in milliseconds
}
}
});
const App: FC = () => {
return (
<BrowserRouter>
<I18nextProvider i18n={i18next}>
<QueryClientProvider client={queryClient}>
<Main />
<ToastContainer />
</QueryClientProvider>
</I18nextProvider>
</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");
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.7634 4.92222C14.6506 4.95068 14.5325 4.95068 14.4198 4.92222H8.04008C6.41809 4.9032 4.83282 5.40536 3.5176 6.3548C2.20238 7.30423 1.22667 8.65079 0.734074 10.1963C0.503887 10.9056 0.387896 11.6469 0.390438 12.3926C0.390438 13.1695 0.390438 13.9464 0.390438 14.7233C0.390438 14.8332 0.412079 14.942 0.454126 15.0435C0.496174 15.145 0.557802 15.2373 0.635496 15.3149C0.713188 15.3926 0.805424 15.4543 0.906935 15.4963C1.00845 15.5384 1.11724 15.56 1.22712 15.56C1.33699 15.56 1.44579 15.5384 1.5473 15.4963C1.64881 15.4543 1.74104 15.3926 1.81874 15.3149C1.89643 15.2373 1.95806 15.145 2.00011 15.0435C2.04216 14.942 2.0638 14.8332 2.0638 14.7233V12.5121C2.05855 11.0817 2.57429 9.69836 3.51465 8.62052C4.45501 7.54269 5.75564 6.84411 7.17352 6.65534C7.44199 6.6331 7.71184 6.6331 7.98032 6.65534H14.5991C14.6671 6.6336 14.7402 6.6336 14.8082 6.65534C14.8082 6.75992 14.7036 6.80475 14.6439 6.87945L11.8649 9.6136C11.7407 9.7257 11.653 9.87245 11.613 10.0349C11.5731 10.1974 11.5827 10.3681 11.6408 10.525C11.6886 10.6771 11.7812 10.8113 11.9065 10.9099C12.0318 11.0086 12.1839 11.0671 12.343 11.0778C12.4613 11.1 12.5832 11.0954 12.6995 11.0644C12.8158 11.0333 12.9237 10.9767 13.0153 10.8985L17.4975 6.41629C17.5841 6.33785 17.6533 6.24216 17.7007 6.13537C17.748 6.02858 17.7725 5.91306 17.7725 5.79625C17.7725 5.67943 17.748 5.56391 17.7007 5.45712C17.6533 5.35033 17.5841 5.25464 17.4975 5.17621L14.3002 1.93407L13.0751 0.738816C12.9996 0.640595 12.9036 0.560032 12.7937 0.502748C12.6838 0.445464 12.5628 0.412839 12.4391 0.407149C12.3153 0.401459 12.1918 0.42284 12.0771 0.469802C11.9625 0.516764 11.8595 0.588175 11.7753 0.679054C11.6883 0.75883 11.6189 0.855804 11.5714 0.963827C11.5239 1.07185 11.4994 1.18856 11.4994 1.30656C11.4994 1.42456 11.5239 1.54128 11.5714 1.6493C11.6189 1.75732 11.6883 1.8543 11.7753 1.93407L14.7634 4.92222Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7209_44189)">
<path d="M2.98855 4.92222C3.10133 4.95068 3.21941 4.95068 3.33219 4.92222H9.71187C11.3339 4.9032 12.9191 5.40536 14.2344 6.3548C15.5496 7.30423 16.5253 8.65079 17.0179 10.1963C17.2481 10.9056 17.3641 11.6469 17.3615 12.3926C17.3615 13.1695 17.3615 13.9464 17.3615 14.7233C17.3615 14.8332 17.3399 14.942 17.2978 15.0435C17.2558 15.145 17.1942 15.2373 17.1165 15.3149C17.0388 15.3926 16.9465 15.4543 16.845 15.4963C16.7435 15.5384 16.6347 15.56 16.5248 15.56C16.415 15.56 16.3062 15.5384 16.2047 15.4963C16.1031 15.4543 16.0109 15.3926 15.9332 15.3149C15.8555 15.2373 15.7939 15.145 15.7518 15.0435C15.7098 14.942 15.6882 14.8332 15.6882 14.7233V12.5121C15.6934 11.0817 15.1777 9.69836 14.2373 8.62052C13.2969 7.54269 11.9963 6.84411 10.5784 6.65534C10.31 6.6331 10.0401 6.6331 9.77164 6.65534H3.1529C3.08487 6.6336 3.01176 6.6336 2.94373 6.65534C2.94373 6.75992 3.04832 6.80475 3.10808 6.87945L5.88705 9.6136C6.01124 9.7257 6.09898 9.87245 6.13893 10.0349C6.17888 10.1974 6.16921 10.3681 6.11116 10.525C6.06338 10.6771 5.97079 10.8113 5.84549 10.9099C5.7202 11.0086 5.56805 11.0671 5.40895 11.0778C5.2906 11.1 5.1688 11.0954 5.05246 11.0644C4.93612 11.0333 4.8282 10.9767 4.73662 10.8985L0.254403 6.41629C0.167835 6.33785 0.0986495 6.24216 0.0513015 6.13537C0.00395355 6.02858 -0.0205078 5.91306 -0.0205078 5.79625C-0.0205078 5.67943 0.00395355 5.56391 0.0513015 5.45712C0.0986495 5.35033 0.167835 5.25464 0.254403 5.17621L3.45172 1.93407L4.67685 0.738816C4.75237 0.640595 4.8484 0.560032 4.95825 0.502748C5.06811 0.445464 5.18914 0.412839 5.3129 0.407149C5.43666 0.401459 5.56017 0.42284 5.67482 0.469802C5.78947 0.516764 5.89249 0.588175 5.9767 0.679054C6.06364 0.75883 6.13306 0.855804 6.18055 0.963827C6.22804 1.07185 6.25256 1.18856 6.25256 1.30656C6.25256 1.42456 6.22804 1.54128 6.18055 1.6493C6.13306 1.75732 6.06364 1.8543 5.9767 1.93407L2.98855 4.92222Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_7209_44189">
<rect width="17.376" height="15.12" fill="white" transform="translate(0 0.44043)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="39" height="54" viewBox="0 0 39 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.1559 34.8412C31.1559 34.8412 24.6651 41.094 19.2416 46.3804V53.5924C18.6935 54.0179 38.4905 34.8845 38.4905 34.8845L19.2416 14.6909V22.3573L31.1559 34.8412Z" fill="#1D1D1B"/>
<path d="M19.1479 39.0818L0 18.643L0.237992 18.405L19.3931 38.8583L19.1479 39.0818Z" fill="#1D1D1B"/>
<path d="M19.1407 37.1635L1.75977 18.6719L1.99776 18.4412L19.3787 36.9399L19.1407 37.1635Z" fill="#1D1D1B"/>
<path d="M19.1334 35.2523L3.52661 18.708L3.7574 18.47L19.3786 35.0215L19.1334 35.2523Z" fill="#1D1D1B"/>
<path d="M19.1262 33.3337L5.28638 18.7366L5.50995 18.5059L19.3714 33.1029L19.1262 33.3337Z" fill="#1D1D1B"/>
<path d="M19.1191 31.4155L7.05347 18.7729L7.26982 18.5349L19.3571 31.1848L19.1191 31.4155Z" fill="#1D1D1B"/>
<path d="M0.375071 18.7368L0.144287 18.4988L19.1407 0L19.3715 0.237996L0.375071 18.7368Z" fill="#1D1D1B"/>
<path d="M2.15645 18.7442L1.91846 18.5134L19.1263 1.73828L19.3571 1.97628L2.15645 18.7442Z" fill="#1D1D1B"/>
<path d="M3.93049 18.7587L3.69971 18.5279L19.1117 3.48364L19.3425 3.71443L3.93049 18.7587Z" fill="#1D1D1B"/>
<path d="M5.71187 18.7658L5.47388 18.5422L19.0973 5.22168L19.3281 5.45968L5.71187 18.7658Z" fill="#1D1D1B"/>
<path d="M7.49326 18.7799L7.24805 18.5564L19.0901 6.95947L19.3137 7.19747L7.49326 18.7799Z" fill="#1D1D1B"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 961.6 222.5" style="enable-background:new 0 0 961.6 222.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#1D1D1B;}
</style>
<g>
<g>
<g>
<g>
<path class="st0" d="M246,180.4h-8.7v24.3h-3.6v-24.3H225v-2.9h21V180.4z"/>
<path class="st0" d="M279.1,205c-2.7,0-5-0.9-6.7-2.7s-2.6-4.2-2.6-7.2v-0.6c0-2,0.4-3.8,1.1-5.4s1.8-2.8,3.2-3.7
s2.9-1.3,4.5-1.3c2.6,0,4.7,0.9,6.1,2.6c1.5,1.7,2.2,4.2,2.2,7.4v1.4h-13.7c0,2,0.6,3.6,1.7,4.8s2.5,1.8,4.2,1.8
c1.2,0,2.3-0.2,3.1-0.7s1.6-1.2,2.2-2l2.1,1.6C285,203.7,282.5,205,279.1,205z M278.7,186.9c-1.4,0-2.6,0.5-3.5,1.5
s-1.5,2.4-1.8,4.3h10.1v-0.3c-0.1-1.8-0.6-3.1-1.4-4.1C281.3,187.4,280.1,186.9,278.7,186.9z"/>
<path class="st0" d="M320.4,202.2c1.2,0,2.3-0.4,3.2-1.1c0.9-0.7,1.4-1.7,1.5-2.8h3.3c-0.1,1.2-0.5,2.3-1.2,3.3
s-1.7,1.9-2.9,2.5c-1.2,0.6-2.5,0.9-3.9,0.9c-2.8,0-4.9-0.9-6.6-2.8c-1.6-1.8-2.4-4.3-2.4-7.5v-0.6c0-2,0.4-3.7,1.1-5.2
s1.8-2.7,3.1-3.6c1.4-0.8,2.9-1.3,4.8-1.3c2.3,0,4.1,0.7,5.6,2c1.5,1.4,2.3,3.1,2.4,5.3h-3.3c-0.1-1.3-0.6-2.4-1.5-3.2
s-2-1.3-3.3-1.3c-1.8,0-3.1,0.6-4.1,1.9s-1.4,3.1-1.4,5.5v0.7c0,2.3,0.5,4.1,1.4,5.4C317.2,201.6,318.6,202.2,320.4,202.2z"/>
<path class="st0" d="M357,186.9c1.5-1.9,3.5-2.8,6-2.8c4.3,0,6.4,2.4,6.5,7.2v13.4H366v-13.4c0-1.5-0.3-2.5-1-3.2s-1.7-1-3.1-1
c-1.1,0-2.1,0.3-3,0.9s-1.5,1.4-2,2.4v14.4h-3.4v-28.7h3.5V186.9z"/>
<path class="st0" d="M399.1,184.5l0.1,2.5c1.5-1.9,3.6-2.9,6.1-2.9c4.3,0,6.4,2.4,6.5,7.2v13.4h-3.5v-13.4c0-1.5-0.3-2.5-1-3.2
s-1.7-1-3.1-1c-1.1,0-2.1,0.3-3,0.9s-1.5,1.4-2,2.4v14.4h-3.5v-20.2h3.4V184.5z"/>
<path class="st0" d="M437.3,194.4c0-2,0.4-3.8,1.2-5.3c0.8-1.6,1.9-2.8,3.2-3.7s3-1.3,4.8-1.3c2.8,0,5,1,6.7,2.9
s2.5,4.4,2.5,7.6v0.2c0,2-0.4,3.7-1.1,5.3s-1.8,2.8-3.2,3.7c-1.4,0.9-3,1.3-4.8,1.3c-2.7,0-5-1-6.7-2.9s-2.5-4.4-2.5-7.6v-0.2
H437.3z M440.8,194.8c0,2.2,0.5,4,1.6,5.4c1,1.4,2.4,2,4.2,2c1.8,0,3.1-0.7,4.2-2.1s1.6-3.3,1.6-5.8c0-2.2-0.5-4-1.6-5.4
s-2.5-2.1-4.2-2.1s-3.1,0.7-4.1,2C441.3,190.3,440.8,192.3,440.8,194.8z"/>
<path class="st0" d="M485.1,204.7h-3.5V176h3.5V204.7z"/>
<path class="st0" d="M511,194.4c0-2,0.4-3.8,1.2-5.3c0.8-1.5,1.9-2.8,3.2-3.7c1.4-0.9,3-1.3,4.8-1.3c2.8,0,5,1,6.7,2.9
s2.5,4.4,2.5,7.6v0.2c0,2-0.4,3.7-1.1,5.3s-1.8,2.8-3.2,3.7s-3,1.3-4.8,1.3c-2.7,0-5-1-6.7-2.9c-1.7-1.9-2.5-4.4-2.5-7.6v-0.2
H511z M514.4,194.8c0,2.2,0.5,4,1.6,5.4c1,1.4,2.4,2,4.2,2s3.2-0.7,4.2-2.1c1-1.4,1.5-3.3,1.5-5.8c0-2.2-0.5-4-1.6-5.4
s-2.5-2.1-4.2-2.1s-3.1,0.7-4.1,2C515,190.3,514.4,192.3,514.4,194.8z"/>
<path class="st0" d="M554.1,194.4c0-3.2,0.7-5.7,2.2-7.5s3.4-2.8,5.8-2.8c2.5,0,4.4,0.9,5.8,2.6l0.2-2.2h3.2v19.7
c0,2.6-0.8,4.7-2.3,6.2c-1.6,1.5-3.6,2.3-6.2,2.3c-1.5,0-2.9-0.3-4.3-0.9c-1.4-0.6-2.5-1.5-3.2-2.6l1.8-2.1
c1.5,1.8,3.3,2.7,5.4,2.7c1.7,0,3-0.5,3.9-1.4s1.4-2.3,1.4-4v-1.7c-1.4,1.6-3.3,2.4-5.7,2.4s-4.3-1-5.7-2.9
S554.1,197.7,554.1,194.4z M557.6,194.8c0,2.3,0.5,4.1,1.4,5.4c0.9,1.3,2.2,2,3.9,2c2.2,0,3.8-1,4.8-3V190
c-1.1-1.9-2.6-2.9-4.8-2.9c-1.7,0-3,0.7-3.9,2C558.1,190.3,557.6,192.2,557.6,194.8z"/>
<path class="st0" d="M604.3,199.6l4.7-15.1h3.7l-8.1,23.3c-1.3,3.4-3.3,5-6,5l-0.7-0.1l-1.3-0.2v-2.8l0.9,0.1
c1.2,0,2.1-0.2,2.7-0.7c0.7-0.5,1.2-1.3,1.6-2.6l0.8-2.1l-7.2-20h3.8L604.3,199.6z"/>
<path class="st0" d="M688.3,196c-0.3,2.9-1.4,5.1-3.2,6.7c-1.8,1.6-4.2,2.3-7.1,2.3c-3.2,0-5.8-1.2-7.7-3.5s-2.9-5.4-2.9-9.2
v-2.6c0-2.5,0.5-4.8,1.4-6.7s2.2-3.4,3.8-4.4s3.6-1.5,5.8-1.5c2.9,0,5.2,0.8,6.9,2.4c1.7,1.6,2.8,3.8,3,6.7h-3.6
c-0.3-2.2-1-3.7-2-4.7s-2.5-1.5-4.4-1.5c-2.3,0-4.1,0.8-5.4,2.5c-1.3,1.7-2,4.1-2,7.2v2.6c0,3,0.6,5.3,1.8,7s3,2.6,5.2,2.6
c2,0,3.5-0.5,4.6-1.4s1.8-2.5,2.1-4.7h3.7V196z"/>
<path class="st0" d="M713,194.4c0-2,0.4-3.8,1.2-5.3s1.9-2.8,3.2-3.7c1.4-0.9,3-1.3,4.8-1.3c2.8,0,5,1,6.7,2.9s2.5,4.4,2.5,7.6
v0.2c0,2-0.4,3.7-1.1,5.3s-1.8,2.8-3.2,3.7s-3,1.3-4.8,1.3c-2.7,0-5-1-6.7-2.9s-2.5-4.4-2.5-7.6v-0.2H713z M716.5,194.8
c0,2.2,0.5,4,1.6,5.4c1,1.4,2.4,2,4.2,2s3.2-0.7,4.2-2.1c1-1.4,1.5-3.3,1.5-5.8c0-2.2-0.5-4-1.6-5.4s-2.5-2.1-4.2-2.1
s-3.1,0.7-4.1,2C717,190.3,716.5,192.3,716.5,194.8z"/>
<path class="st0" d="M760.3,184.5l0.1,2.2c1.5-1.7,3.5-2.6,6-2.6c2.8,0,4.8,1.1,5.8,3.2c0.7-1,1.5-1.8,2.6-2.4
c1.1-0.6,2.4-0.9,3.8-0.9c4.4,0,6.7,2.3,6.8,7v13.5h-3.5v-13.3c0-1.4-0.3-2.5-1-3.2s-1.8-1.1-3.3-1.1c-1.3,0-2.3,0.4-3.2,1.1
c-0.8,0.8-1.3,1.8-1.5,3.1v13.4h-3.5v-13.2c0-2.9-1.4-4.4-4.3-4.4c-2.3,0-3.8,1-4.7,2.9v14.8h-3.5v-20.2h3.4V184.5z"/>
<path class="st0" d="M828.9,194.8c0,3.1-0.7,5.6-2.1,7.4c-1.4,1.9-3.3,2.8-5.7,2.8c-2.5,0-4.4-0.8-5.8-2.3v9.7h-3.5v-28h3.2
l0.2,2.2c1.4-1.7,3.4-2.6,5.9-2.6c2.4,0,4.3,0.9,5.8,2.7c1.4,1.8,2.1,4.4,2.1,7.6v0.5H828.9z M825.5,194.4
c0-2.3-0.5-4.1-1.5-5.4c-1-1.3-2.3-2-4-2c-2.1,0-3.7,0.9-4.7,2.8v9.7c1,1.8,2.6,2.8,4.7,2.8c1.7,0,3-0.7,4-2
S825.5,197,825.5,194.4z"/>
<path class="st0" d="M867,204.7c-0.2-0.4-0.4-1.1-0.5-2.1c-1.6,1.7-3.5,2.5-5.8,2.5c-2,0-3.6-0.6-4.9-1.7s-1.9-2.6-1.9-4.3
c0-2.1,0.8-3.7,2.4-4.9c1.6-1.2,3.9-1.7,6.8-1.7h3.4v-1.7c0-1.2-0.4-2.2-1.1-2.9c-0.7-0.7-1.8-1.1-3.2-1.1
c-1.2,0-2.3,0.3-3.1,0.9c-0.8,0.6-1.3,1.4-1.3,2.3h-3.5c0-1,0.4-2,1.1-2.9c0.7-0.9,1.7-1.7,2.9-2.2s2.6-0.8,4-0.8
c2.3,0,4.2,0.6,5.5,1.7c1.3,1.2,2,2.8,2.1,4.8v9.3c0,1.9,0.2,3.3,0.7,4.4v0.3H867V204.7z M861.3,202c1.1,0,2.1-0.3,3.1-0.8
c1-0.6,1.7-1.3,2.1-2.2v-4.1h-2.7c-4.2,0-6.4,1.2-6.4,3.7c0,1.1,0.4,1.9,1.1,2.5C859.2,201.7,860.2,202,861.3,202z"/>
<path class="st0" d="M899.9,184.5l0.1,2.5c1.5-1.9,3.6-2.9,6.1-2.9c4.3,0,6.4,2.4,6.5,7.2v13.4h-3.5v-13.4c0-1.5-0.3-2.5-1-3.2
s-1.7-1-3.1-1c-1.1,0-2.1,0.3-3,0.9s-1.5,1.4-2,2.4v14.4h-3.5v-20.2h3.4V184.5z"/>
<path class="st0" d="M945.6,199.6l4.7-15.1h3.7l-8.1,23.3c-1.3,3.4-3.3,5-6,5l-0.7-0.1l-1.3-0.2v-2.8l0.9,0.1
c1.2,0,2.1-0.2,2.7-0.7c0.7-0.5,1.2-1.3,1.6-2.6l0.8-2.1l-7.2-20h3.8L945.6,199.6z"/>
</g>
</g>
<path class="st0" d="M221.6,148.7V44.1h29.5c9.1,0,17.1,2,24.1,6s12.4,9.7,16.2,17.2c3.8,7.4,5.7,15.9,5.8,25.6v6.7
c0,9.9-1.9,18.5-5.7,25.9c-3.8,7.4-9.2,13.1-16.3,17.1c-7.1,4-15.3,6-24.7,6.1L221.6,148.7L221.6,148.7z M235.4,55.4v82h14.5
c10.6,0,18.9-3.3,24.8-9.9c5.9-6.6,8.9-16,8.9-28.3v-6.1c0-11.9-2.8-21.1-8.4-27.7s-13.5-9.9-23.7-10L235.4,55.4L235.4,55.4z"/>
<path class="st0" d="M627.1,148.7h-13.9l-52.7-80.6v80.6h-13.9V44.1h13.9l52.8,81v-81H627v104.6H627.1z"/>
<path class="st0" d="M900.9,100.1l-12.8,13.3v35.3h-13.8V44.1h13.8v51.7l46.5-51.7h16.7l-41.2,46.2l44.4,58.4H938L900.9,100.1z"/>
<polygon class="st0" points="423.2,61.1 455.1,148.7 469.2,148.7 429.2,44.1 417.1,44.1 377.2,148.7 391.4,148.7 "/>
<polygon class="st0" points="750.6,61.1 782.6,148.7 796.7,148.7 756.7,44.1 744.6,44.1 704.7,148.7 718.9,148.7 "/>
</g>
<path class="st0" d="M132.1,140.4c0,0-26.1,25.2-48,46.5v28.9c-2.2,1.7,77.6-75.4,77.6-75.4L84.1,59v31L132.1,140.4z"/>
<g>
<polygon class="st0" points="83.7,157.5 6.5,75.1 7.5,74.2 84.7,156.6 "/>
<polygon class="st0" points="83.7,149.8 13.6,75.2 14.6,74.3 84.6,148.9 "/>
<polygon class="st0" points="83.7,142.1 20.7,75.4 21.7,74.4 84.6,141.1 "/>
<polygon class="st0" points="83.6,134.3 27.8,75.5 28.7,74.6 84.6,133.4 "/>
<polygon class="st0" points="83.6,126.6 34.9,75.6 35.8,74.7 84.6,125.7 "/>
</g>
<g>
<polygon class="st0" points="8,75.5 7.1,74.5 83.7,0 84.6,0.9 "/>
<polygon class="st0" points="15.2,75.5 14.3,74.6 83.6,7 84.5,7.9 "/>
<polygon class="st0" points="22.4,75.6 21.4,74.7 83.6,14 84.5,14.9 "/>
<polygon class="st0" points="29.5,75.6 28.6,74.7 83.5,21 84.4,22 "/>
<polygon class="st0" points="36.7,75.7 35.7,74.8 83.5,28 84.4,29 "/>
</g>
</g>
</svg>

After

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

+42
View File
@@ -0,0 +1,42 @@
import { FC, ReactNode } from 'react'
import { clx } from '../helpers/utils'
import MoonLoader from "react-spinners/ClipLoader"
interface ButtonProps {
label?: string;
children?: ReactNode;
className?: string;
loading?: boolean;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
variant?: 'primary' | 'secondary'
}
const Button: FC<ButtonProps> = (props) => {
const buttonClass = clx(
'w-full bg-primary text-white cursor-pointer rounded-xl font-normal text-xs md:text-sm h-10 px-3 md:px-4 transition-all duration-200 hover:bg-opacity-90 disabled:opacity-50 disabled:cursor-not-allowed',
props.variant === 'secondary' && 'bg-[#ECEEF5] text-black',
props.className
)
return (
<button
onClick={props.onClick}
type={props.type || 'button'}
disabled={props.disabled || props.loading}
className={buttonClass}
>
{props.loading ? (
<MoonLoader className='mt-1.5' size={15} color={props.variant === 'secondary' ? '#000' : '#fff'} />
) : (
props.children || props.label
)}
</button>
)
}
export default Button
+41
View File
@@ -0,0 +1,41 @@
import { FC, useState } from 'react'
import ColorImage from '@/assets/images/color.png'
import { HexColorPicker } from 'react-colorful'
type Props = {
defaultColor?: string
changeColor: (color: string) => void,
label: string
}
const ColorPicker: FC<Props> = ({ defaultColor, changeColor, label }) => {
const [showPicker, setShowPicker] = useState(false)
const [color, setColor] = useState<string>(defaultColor || '#000')
return (
<div>
<div>
{label}
</div>
<div onClick={() => setShowPicker(!showPicker)}
className='mt-2 relative cursor-pointer h-10 border border-[#D0D0D0] rounded-xl px-4 flex justify-between items-center'>
<div className='flex gap-1.5 items-center'>
<div style={{ background: color }} className='size-4 rounded-full'></div>
<div className='text-description text-sm dltr text-right'>{color}</div>
</div>
<img src={ColorImage} alt="color" className='size-6' />
{showPicker && (
<div style={{ position: "absolute", top: "50px", zIndex: 10 }}>
<HexColorPicker color={color} onChange={(newColor) => {
setColor(newColor)
changeColor(newColor)
}} />
</div>
)}
</div>
</div>
)
}
export default ColorPicker
+87
View File
@@ -0,0 +1,87 @@
import { useState, useEffect, FC } from 'react';
import DatePicker from 'react-multi-date-picker';
import persian from 'react-date-object/calendars/persian';
import persian_fa from 'react-date-object/locales/persian_fa';
import DateObject from 'react-date-object';
import { Calendar } from 'iconsax-react';
import { clx } from '../helpers/utils';
type Props = {
onChange: (date: string) => void;
defaultValue?: string;
error_text?: string;
placeholder: string;
reset?: boolean;
isDateTime?: boolean;
className?: string;
label?: string,
readOnly?: boolean;
};
const DatePickerComponent: FC<Props> = (props: Props) => {
const [value, setValue] = useState<DateObject | null>(null);
useEffect(() => {
if (props.reset) {
setValue(null);
}
}, [props.reset]);
useEffect(() => {
if (value) {
const formattedDate = `${value.year}/${value.month.number}/${value.day}`;
props.onChange(formattedDate);
}
}, [value]);
useEffect(() => {
if (props.defaultValue && !value) {
const defaultDate = new DateObject({
date: props.defaultValue,
calendar: persian,
locale: persian_fa,
});
setValue(defaultDate);
}
}, [props.defaultValue, value]);
return (
<div className="w-full">
{
props.label &&
<div className='text-sm'>
{props.label}
</div>
}
<div className={clx(
'relative',
props.readOnly && 'readOny',
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={props.className}
readOnly={props.readOnly}
/>
{props.error_text && props.error_text !== '' && (
<div className="text-xs text-right text-red-600 mt-2 mr-2 font-medium">
{props.error_text}
</div>
)}
<Calendar
size={20}
color='black'
className='absolute top-0 bottom-0 my-auto left-2 pointer-events-none'
/>
</div>
</div>
);
};
export default DatePickerComponent;
+30
View File
@@ -0,0 +1,30 @@
import { FC, Fragment } from 'react'
import Td from './Td'
import Skeleton from 'react-loading-skeleton'
type Props = {
tdCount: number,
trCount?: number,
}
const DefaultTableSkeleton: FC<Props> = ({ tdCount, trCount = 5 }) => {
return (
<Fragment>
{
Array.from({ length: trCount }).map((_, rowIndex) => (
<tr className="w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5]" key={rowIndex}>
{
Array.from({ length: tdCount }).map((_, colIndex) => (
<Td text={''} key={colIndex}>
<Skeleton />
</Td>
))
}
</tr>
))
}
</Fragment>
)
}
export default DefaultTableSkeleton
+15
View File
@@ -0,0 +1,15 @@
import { 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
+191
View File
@@ -0,0 +1,191 @@
import { FC, useEffect, useState, ChangeEvent } from 'react';
import { Filter, CloseCircle } from 'iconsax-react';
import DatePicker from './DatePicker';
import Input from './Input';
import Select, { ItemsSelectType } from './Select';
// تعریف نوع داده برای فیلدهای مختلف
export type DateFieldType = {
type: 'date';
name: string;
placeholder: string;
defaultValue?: string;
};
export type SelectFieldType = {
type: 'select';
name: string;
placeholder: string;
options: ItemsSelectType[];
defaultValue?: string;
};
export type InputFieldType = {
type: 'input';
name: string;
placeholder: string;
defaultValue?: string;
};
export type FieldType = DateFieldType | SelectFieldType | InputFieldType;
// تعریف نوع داده برای مقادیر فیلترها
export type FilterValues = Record<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 = "mt-6 md:mt-8 xl:mt-10 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" // پیش‌فرض فیلد سرچ با نام search
}) => {
const [filters, setFilters] = useState<FilterValues>({});
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
// تنظیم مقادیر اولیه
useEffect(() => {
if (Object.keys(initialValues).length > 0) {
setFilters(initialValues);
} else {
// تنظیم مقادیر پیش‌فرض از فیلدها
const defaultValues: FilterValues = {};
fields.forEach(field => {
if ('defaultValue' in field && field.defaultValue !== undefined) {
defaultValues[field.name] = field.defaultValue;
}
});
if (Object.keys(defaultValues).length > 0) {
setFilters(defaultValues);
onChange(defaultValues);
}
}
}, [fields, initialValues, onChange]);
const handleChange = (name: string, value: string | null) => {
const newFilters = { ...filters, [name]: value };
setFilters(newFilters);
onChange(newFilters);
};
const handleInputChange = (name: string, event: ChangeEvent<HTMLInputElement>) => {
handleChange(name, event.target.value);
};
const renderField = (field: FieldType) => {
const currentValue = filters[field.name];
switch (field.type) {
case 'date':
return (
<DatePicker
key={field.name}
placeholder={field.placeholder}
onChange={(value) => handleChange(field.name, value)}
defaultValue={currentValue || field.defaultValue || ''}
/>
);
case 'select':
return (
<Select
key={field.name}
placeholder={field.placeholder}
onChange={(e) => handleChange(field.name, e.target.value)}
defaultValue={currentValue || field.defaultValue || ''}
items={field.options}
/>
);
case 'input':
return (
<Input
key={field.name}
placeholder={field.placeholder}
variant="search"
className="w-full md:max-w-[200px]"
value={currentValue || field.defaultValue || ''}
onChange={(e) => handleInputChange(field.name, e)}
/>
);
}
};
// جداسازی فیلد جستجو از سایر فیلدها
const searchFieldObj = fields.find(field => field.name === searchField);
const otherFields = fields.filter(field => field.name !== searchField);
return (
<div className={className}>
{/* آیکون فیلتر برای موبایل */}
<div className="flex md:hidden items-center justify-between w-full mb-2">
<button
onClick={() => setIsFiltersOpen(!isFiltersOpen)}
className="flex items-center gap-2 px-3 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<Filter size={18} color="#000" />
<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}>
{otherFields.map(renderField)}
</div>
{searchFieldObj && (
<div className="w-full md:w-auto">
{renderField(searchFieldObj)}
</div>
)}
</div>
{/* فیلترها برای موبایل (قابل باز و بسته شدن) */}
{isFiltersOpen && (
<div className="md:hidden w-full animate-fadeInDown">
<div className="bg-gradient-to-br from-white to-gray-50 border border-gray-200 rounded-xl p-5 space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-gray-100">
<div className="flex items-center gap-2">
<Filter size={16} color="#6B7280" />
<span className="text-sm font-medium text-gray-700">فیلتر کردن نتایج</span>
</div>
<button
onClick={() => setIsFiltersOpen(false)}
className="p-1 hover:bg-gray-100 rounded-full transition-colors"
>
<CloseCircle size={18} color="#6B7280" />
</button>
</div>
{otherFields.map(field => (
<div key={field.name} className="w-full">
<label className="block text-xs font-medium text-gray-600 mb-2">
{field.placeholder}
</label>
<div className="transform hover:scale-[1.02] transition-transform duration-200">
{renderField(field)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default Filters;
+125
View File
@@ -0,0 +1,125 @@
import { FC, InputHTMLAttributes, useEffect, useState } from 'react'
import { clx } from '../helpers/utils';
import { Eye, 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;
endIcon?: React.ReactNode;
} & 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' && '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` والد
props.onChange?.({
...event,
target: {
...event.target,
value: inputValue,
},
});
} else {
props.onChange?.(event);
}
};
useEffect(() => {
if (props.value) {
setFormattedValue(formatNumber(props.value as string));
} else {
setFormattedValue('');
}
}, [props.value])
useEffect(() => {
if (props.variant === 'search' && props.onChangeSearchFinal) {
const timeout = setTimeout(() => {
props.onChangeSearchFinal?.(search)
}, 1000)
return () => clearTimeout(timeout)
}
}, [search, props])
return (
<div className='w-full'>
<label className='text-sm'>
{props.label}
</label>
<div className={clx(
'w-full relative',
props.label && 'mt-1'
)}>
<input {...props} onChange={(e) => {
setSearch(e.target.value)
handleInputChange(e)
}} value={props.seprator ? formattedValue : props.value} type={props.type === 'password' && showPassword ? 'text' : props.type === 'password' ? 'password' : undefined} className={inputClass} />
{
props.type === 'password' &&
<Eye onClick={() => setShowPassword((oldValue) => !oldValue)} size={20} color='#8C90A3' className='absolute top-0 bottom-0 cursor-pointer my-auto left-3' />
}
{
props.variant === 'search' &&
<SearchNormal size={20} color='#8C90A3' className='absolute pointer-events-none top-0 w-5 bottom-0 my-auto right-3' />
}
{
props.endIcon &&
<div className='absolute flex items-center pointer-events-none top-0 bottom-0 cursor-pointer my-auto left-3'>
{props.endIcon}
</div>
}
{
props.error_text &&
<Error
errorText={props.error_text}
/>
}
</div>
</div>
)
}
export default Input
+109
View File
@@ -0,0 +1,109 @@
import React from "react";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
}) => {
// اگر فقط یک صفحه داریم، pagination نمایش داده نمی‌شود
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pageNumbers: (number | string)[] = [];
const maxVisiblePages = window.innerWidth < 768 ? 3 : 5; // تعداد کمتر برای موبایل
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// نمایش صفحه اول
pageNumbers.push(1);
if (currentPage > 3) {
pageNumbers.push("...");
}
// صفحات میانی
const start = Math.max(2, Math.min(currentPage - 1, totalPages - 3));
const end = Math.min(totalPages - 1, Math.max(currentPage + 1, 4));
for (let i = start; i <= end; i++) {
if (!pageNumbers.includes(i)) {
pageNumbers.push(i);
}
}
if (currentPage < totalPages - 2) {
pageNumbers.push("...");
}
// نمایش صفحه آخر
if (!pageNumbers.includes(totalPages)) {
pageNumbers.push(totalPages);
}
}
return pageNumbers;
};
const pageNumbers = getPageNumbers();
return (
<div className="flex justify-center items-center gap-1 md:gap-2 py-4 md:py-6 mt-3 md:mt-4 border-t border-gray-100">
{/* دکمه صفحه قبل */}
<button
className={`px-2 md:px-3 py-2 text-xs md:text-sm rounded-lg transition-colors ${currentPage === 1
? "text-gray-400 cursor-not-allowed"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`}
onClick={() => currentPage > 1 && onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
قبلی
</button>
{/* شماره صفحات */}
<div className="flex gap-1">
{pageNumbers.map((page, index) =>
typeof page === "number" ? (
<button
key={index}
className={`w-8 h-8 md:w-10 md:h-10 text-xs md:text-sm rounded-lg transition-colors ${currentPage === page
? "bg-primary text-white shadow-sm"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`}
onClick={() => onPageChange(page)}
>
{page}
</button>
) : (
<span key={index} className="flex items-center px-1 md:px-2 text-gray-400 text-xs md:text-sm">
{page}
</span>
)
)}
</div>
{/* دکمه صفحه بعد */}
<button
className={`px-2 md:px-3 py-2 text-xs md:text-sm rounded-lg transition-colors ${currentPage === totalPages
? "text-gray-400 cursor-not-allowed"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-800"
}`}
onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
بعدی
</button>
</div>
);
};
export default Pagination;
+20
View File
@@ -0,0 +1,20 @@
import { FC } from 'react'
type Props = {
isActive: boolean,
value: string,
onChange: (value: string) => 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 { FC } from 'react'
import Radio from './Radio'
type Props = {
items: {
label: string
value: string
}[]
selected: string
onChange: (value: string) => void
}
const RadioGroup: FC<Props> = (props: Props) => {
return (
<div className='flex justify-between xl:justify-start 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
+117
View File
@@ -0,0 +1,117 @@
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;
}
const RowActionsDropdown: React.FC<RowActionsDropdownProps> = ({ actions, className = '' }) => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = 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 handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (!isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const isMobile = window.innerWidth < 768;
const dropdownWidth = isMobile ? 140 : 150;
const dropdownHeight = actions.length * (isMobile ? 36 : 40); // تخمین ارتفاع منو
let left = rect.left + window.scrollX - dropdownWidth + rect.width;
let top = rect.bottom + window.scrollY;
// بررسی اینکه منو از سمت راست خارج نشود
if (left + dropdownWidth > window.innerWidth) {
left = rect.left + window.scrollX - dropdownWidth;
}
// بررسی اینکه منو از سمت چپ خارج نشود
if (left < 0) {
left = isMobile ? 20 : 40;
}
// بررسی اینکه منو از پایین خارج نشود
if (top + dropdownHeight > window.innerHeight + window.scrollY) {
top = rect.top + window.scrollY - dropdownHeight;
}
setPosition({ top, left });
}
setIsOpen(!isOpen);
};
const handleActionClick = (action: RowActionItem) => {
action.onClick();
setIsOpen(false);
};
const renderDropdown = () => {
if (!isOpen) return null;
return createPortal(
<div
ref={dropdownRef}
className="fixed py-1 md:py-2 bg-white rounded-xl md:rounded-2xl shadow-lg z-[9999] min-w-[140px] md:min-w-[150px]"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{actions.map((action, index) => (
<button
key={index}
onClick={() => handleActionClick(action)}
className={`w-full text-right px-2 md:px-3 py-1 md:py-1.5 text-xs md:text-[13px] flex items-center gap-2 hover:bg-gray-50 ${index === 0 ? 'rounded-t-lg' : ''
} ${action.className || ''}`}
>
{action.icon && <span className="ml-2">{action.icon}</span>}
{action.label}
</button>
))}
</div>,
document.body
);
};
return (
<div className={className}>
<button
ref={buttonRef}
onClick={handleToggle}
className="p-1 hover:bg-gray-100 rounded-full transition-colors"
>
<More size={14} color="black" className="rotate-90" />
</button>
{renderDropdown()}
</div>
);
};
export default RowActionsDropdown;
+65
View File
@@ -0,0 +1,65 @@
import { FC, SelectHTMLAttributes } from 'react'
import { clx } from '../helpers/utils'
import { ArrowDown2 } from 'iconsax-react'
export type ItemsSelectType = {
value: string,
label: string,
}
type Props = {
className?: string,
items: ItemsSelectType[],
error_text?: string,
placeholder?: string,
label?: string,
readOnly?: boolean,
} & SelectHTMLAttributes<HTMLSelectElement>
const Select: FC<Props> = (props: Props) => {
return (
<div className='w-full'>
{
props.label &&
<label className='text-sm'>
{props.label}
</label>
}
<div className='relative'>
<select {...props} className={clx(
'w-full text-black relative block border appearance-none border-border px-2.5 h-10 text-sm rounded-2.5 bg-white',
props.readOnly && 'bg-gray-100 border-0 text-description',
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 pointer-events-none',
)} />
</div>
{
props.error_text && props.error_text !== '' ?
<div className='text-xs text-right text-red-600 mt-2 mr-2 font-medium'>
{props.error_text}
</div>
: null
}
</div>
)
}
export default Select
+363
View File
@@ -0,0 +1,363 @@
import React, { Fragment, useState } from 'react';
import DefaultTableSkeleton from './DefaultTableSkeleton';
import Td from './Td';
import { TableProps, RowDataType, ColumnType } from './types/TableTypes';
import { Checkbox } from './ui/checkbox';
import RowActionsDropdown from './RowActionsDropdown';
import Pagination from './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) : (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) : (item[col.key] as React.ReactNode)}
</div>
))}
</div>
)}
</div>
{rowActions && (
<div
className="flex-shrink-0 relative z-[5]"
onClick={(e) => e.stopPropagation()}
>
<RowActionsDropdown actions={rowActions(item)} />
</div>
)}
</div>
))}
</div>
);
};
const renderTableBody = () => {
if (isLoading) {
return (
<Fragment>
<DefaultTableSkeleton tdCount={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} trCount={emptyRowsCount} />
</Fragment>
);
}
if (!data || data.length === 0) {
return (
<tr>
<td colSpan={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} className="px-3 md:px-6 py-6 md:py-8 text-center text-gray-500">
{noDataMessage}
</td>
</tr>
);
}
return (data || []).map((item, rowIndex) => (
<tr
key={item.id}
className={`w-full h-[64px] md:h-[74px] bg-white border-t border-[#EAEDF5] hover:bg-[#F4F5F9] ${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) : 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 Table;
/*
مثال استفاده با pagination:
import Table from '@/components/Table';
import { useState } from 'react';
const MyComponent = () => {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 10;
const columns = [
{ title: 'نام', key: 'name' },
{ title: 'ایمیل', key: 'email' },
];
const data = [
{ id: 1, name: 'علی', email: 'ali@example.com' },
{ id: 2, name: 'فاطمه', email: 'fateme@example.com' },
];
const handlePageChange = (page: number) => {
setCurrentPage(page);
// اینجا می‌توانید API call جدید برای دریافت داده‌های صفحه جدید بزنید
};
return (
<Table
columns={columns}
data={data}
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange
}}
/>
);
};
*/
+50
View File
@@ -0,0 +1,50 @@
import { FC, 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={20}
className='px-4 md:px-8 xl:px-10 max-w-full w-fit items-center text-description mx-auto backdrop-blur-md border-2 border-white gap-4 md:gap-6 xl:gap-10 flex h-[60px] md:h-[70px] xl:h-[80px] rounded-[20px] md:rounded-[28px] xl:rounded-[32px] bg-white bg-opacity-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-[10px] md:mt-[12px] xl:mt-[15px] items-center gap-1 md:gap-2 cursor-pointer',
index === 0 && 'pr-[15px] md:pr-[20px] xl:pr-[30px]',
index === props.items.length - 1 && props.items.length > 3 && 'pl-[5px]',
props.active === item.value && 'text-black'
)}>
{item.icon}
<div className='text-[10px] md:text-xs'>
{item.label}
</div>
</SwiperSlide>
)
})
}
</Swiper>
)
}
export default Tabs
+23
View File
@@ -0,0 +1,23 @@
import { 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
+36
View File
@@ -0,0 +1,36 @@
import { FC, 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'>
<label className='text-sm'>
{props.label}
</label>
<textarea
className={clx(
'border border-border leading-7 w-full rounded-xl px-4 py-3 min-h-[100px] mt-1 text-xs',
props.readOnly && 'bg-gray-100 border-0 text-description'
)}
{...props}
>
</textarea>
{
props.error_text &&
<Error errorText={props.error_text} />
}
</div>
)
}
export default Textarea
+72
View File
@@ -0,0 +1,72 @@
import { CloseCircle, InfoCircle, TickCircle } from 'iconsax-react';
import React, { useState, useEffect } from 'react';
interface Toast {
id: string;
message: string;
type?: 'success' | 'error' | 'info';
isExiting?: boolean;
}
let addToast: (toast: Toast) => void;
const ToastContainer: React.FC = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
// ثبت toast جدید
const showToast = (toast: Toast) => {
setToasts((prev) => [...prev, toast]);
// حذف خودکار بعد از ۳ ثانیه
setTimeout(() => {
setToasts((prev) => prev.map(t =>
t.id === toast.id ? { ...t, isExiting: true } : t
));
// حذف از DOM بعد از اتمام انیمیشن
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== toast.id));
}, 300); // مدت زمان انیمیشن خروج
}, 3000);
};
// تخصیص تابع نمایش toast به متغیر سراسری
useEffect(() => {
addToast = showToast;
}, []);
return (
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`px-4 flex items-center gap-2 backdrop-blur-2xl h-16 min-w-[300px] rounded-2xl shadow-md
${toast.isExiting ? 'animate-slide-out-right' : 'animate-slide-in-right'} ${toast.type === 'success'
? 'bg-white/70 text-black'
: toast.type === 'error'
? 'bg-white/70 text-black'
: 'bg-white/70 text-black'
}`}
style={{
animationFillMode: 'forwards',
animationDuration: '0.3s',
animationTimingFunction: 'ease-out'
}}
>
{
toast.type === 'success' ? <TickCircle className='size-5' color='green' /> :
toast.type === 'error' ? <CloseCircle className='size-5' color='red' /> :
<InfoCircle className='size-5' color='blue' />
}
{toast.message}
</div>
))}
</div>
);
};
export const toast = (message?: string, type: 'success' | 'error' | 'info' = 'info') => {
addToast({ id: Date.now().toString(), message: message || '', type });
};
export default ToastContainer;
+87
View File
@@ -0,0 +1,87 @@
import { 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)
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
props.onChange && props.onChange(array)
} else {
setFiles([acceptedFiles[0]])
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
props.onChange && props.onChange([acceptedFiles[0]])
}
}, [files])
const { getRootProps, getInputProps } = useDropzone({ onDrop })
const handleRemove = (index: number) => {
const array = [...files]
array.splice(index, 1)
setFiles(array)
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
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
+161
View File
@@ -0,0 +1,161 @@
import { CloseCircle, DocumentUpload, Gallery } from 'iconsax-react'
import { 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,
isReset?: boolean
}
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]])
}
}, [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])
useEffect(() => {
if (props.isReset) {
setFiles([])
setPerviews([])
}
}, [props.isReset])
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='mt-4 flex gap-4 items-center'>
{
perviews && perviews.map((item, index) => {
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(
'size-10 rounded-full object-cover',
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'>
<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'>
<CloseCircle onClick={() => handleDelete(index)} className='size-4 ' color='red' />
</div>
</div>
</div>
)
}
else
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='size-10 rounded-full object-cover' />
<div className='absolute -left-2 -top-2 shadow-md bg-white size-5 rounded-full flex justify-center items-center'>
<CloseCircle onClick={() => handleDelete(index)} className='size-4 ' color='red' />
</div>
</div>
</div>
)
})
}
</div>
) : null
}
</div>
)
}
export default UploadBoxDraggble
+50
View File
@@ -0,0 +1,50 @@
import { CloseCircle, Paperclip2 } from 'iconsax-react'
import { FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
type Props = {
title?: string
}
const UploadButton: FC<Props> = (props) => {
const { t } = useTranslation()
const { title } = props
const [files, setFiles] = useState<File[]>([])
const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles((prev) => [...prev, ...acceptedFiles])
}, [])
const { getRootProps, getInputProps } = useDropzone({ onDrop })
const handleRemove = (index: number) => {
const array = [...files]
array.splice(index, 1)
setFiles(array)
}
return (
<div className='flex flex-col gap-4 sm:flex-row'>
<div {...getRootProps()} className='h-10 bg-[#ECEEF5] rounded-full flex gap-2 items-center px-3 cursor-pointer'>
<input {...getInputProps()} />
<Paperclip2 size={20} color='#000' />
<div className='text-xs'>{title ? title : t('attachment_select')}</div>
</div>
{files.map((file, index) => (
<div key={file.name} className='bg-[#EBEDF5] text-xs flex gap-2 h-10 rounded-full items-center px-3'>
<span>{file.name}</span>
<CloseCircle size={19} color='#D52903' className='cursor-pointer' onClick={() => handleRemove(index)} />
</div>
))}
</div>
)
}
export default UploadButton
+41
View File
@@ -0,0 +1,41 @@
import { ReactNode } from "react";
import { RowActionItem } from "../RowActionsDropdown";
export type RowDataType = Record<string, unknown> & { id: string | number };
export interface ColumnType<T extends RowDataType = RowDataType> {
title: string;
key: string;
render?: (item: T) => React.ReactNode;
width?: string | number;
align?: "left" | "center" | "right";
sortable?: boolean;
className?: string;
}
export interface PaginationConfig {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export interface TableProps<T extends RowDataType = RowDataType> {
columns: ColumnType<T>[];
data?: T[];
isLoading?: boolean;
onRowClick?: (id: T["id"], item: T) => void;
className?: string;
rowClassName?: string | ((item: T, index: number) => string);
noDataMessage?: React.ReactNode;
headerClassName?: string;
emptyRowsCount?: number;
showHeader?: boolean;
actions?: ReactNode;
actionsClassName?: string;
actionsPosition?: "top" | "header-replace" | "above-header";
selectable?: boolean;
selectedActions?: ReactNode;
onSelectionChange?: (selectedRows: T[]) => void;
rowActions?: (item: T) => RowActionItem[];
pagination?: PaginationConfig;
}
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+25
View File
@@ -0,0 +1,25 @@
import axios from "axios";
import { getToken } from "./func";
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
});
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
return Promise.reject(error);
}
);
axiosInstance.interceptors.request.use(async function (config) {
const token = getToken();
config.headers.Authorization = "Bearer " + token;
config.headers["x-business-id"] = localStorage.getItem(
import.meta.env.VITE_WORKSPACE_ID
);
return config;
});
export default axiosInstance;
+22
View File
@@ -0,0 +1,22 @@
import axios from "axios";
import { getToken } from "./func";
const axiosDanak = axios.create({
baseURL: import.meta.env.VITE_DANAK_BASE_URL,
});
axiosDanak.interceptors.response.use(
(response) => response,
(error) => {
return Promise.reject(error);
}
);
axiosDanak.interceptors.request.use(async function (config) {
const token = getToken();
config.headers.Authorization = "Bearer " + token;
return config;
});
export default axiosDanak;
+66
View File
@@ -0,0 +1,66 @@
import Cookie from "js-cookie";
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 Cookie.get(import.meta.env.VITE_TOKEN_NAME);
};
export const getRefreshToken = () => {
return Cookie.get(import.meta.env.VITE_REFRESH_TOKEN_NAME);
};
const isProduction = import.meta.env.MODE === "production";
const domain = isProduction ? ".danakcorp.com" : undefined;
export const setToken = async (token: string) => {
Cookie.set(import.meta.env.VITE_TOKEN_NAME, token, { domain });
};
export const setRefreshToken = async (refreshToken: string) => {
Cookie.set(import.meta.env.VITE_REFRESH_TOKEN_NAME, refreshToken, {
domain,
});
};
export const removeToken = async () => {
Cookie.remove(import.meta.env.VITE_TOKEN_NAME, { domain, path: "/" });
};
export const removeRefreshToken = async () => {
Cookie.remove(import.meta.env.VITE_REFRESH_TOKEN_NAME, { domain, path: "/" });
};
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 const convertToGB = (bytes: number): string => {
const GB = bytes / (1024 * 1024 * 1024);
return GB.toFixed(2);
};
+50
View File
@@ -0,0 +1,50 @@
import axios, { AxiosRequestConfig } from "axios";
import { getToken } from "./func";
const orvalAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
});
orvalAxiosInstance.interceptors.response.use(
(response) => response,
(error) => {
return Promise.reject(error);
}
);
orvalAxiosInstance.interceptors.request.use(async (config) => {
const token = getToken();
config.headers["Content-Type"] = "application/json";
config.headers["Accept"] = "application/json";
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
const businessId = localStorage.getItem(import.meta.env.VITE_WORKSPACE_ID);
if (businessId) {
config.headers["x-business-id"] = businessId;
}
return config;
});
export const customAxiosInstance = <T = unknown>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig
): Promise<T> => {
const source = axios.CancelToken.source();
const promise = orvalAxiosInstance({
...config,
...options,
cancelToken: source.token,
}).then(({ data }) => data);
// @ts-expect-error - Adding cancel method to promise
promise.cancel = () => {
source.cancel("Query was cancelled");
};
return promise;
};
+20
View File
@@ -0,0 +1,20 @@
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> = T | U extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U;
export type ItemsSelectType = {
value: string;
label: string;
};
export type ErrorType = Error & {
response?: {
data?: {
error: {
message: string[];
};
};
};
};
+11
View File
@@ -0,0 +1,11 @@
import { twMerge } from "tailwind-merge";
/**
* Concatenates truthy classes into a space-separated string.
*
* @param classes - The classes to concatenate.
* @returns The concatenated classes.
*/
export const clx = (...classes: (string | boolean | undefined)[]): string => {
return twMerge(classes.filter(Boolean).join(" "));
};
+249
View File
@@ -0,0 +1,249 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
html {
direction: rtl;
overflow: hidden;
max-width: 100%;
}
body {
font-family: irancell;
margin: 0;
padding: 0;
width: 100%;
direction: rtl;
overflow: hidden !important;
background: #eceef6;
font-weight: 300;
overflow: hidden;
font-feature-settings: "lnum"; /* اطمینان از نمایش اعداد انگلیسی */
}
/* ریسپانسیو شدن برای صفحات کوچک */
@media (max-width: 768px) {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden !important; /* اعمال overflow: hidden با !important */
}
html,
body,
#root {
min-height: 100% !important;
height: 100%;
}
* {
outline: none;
}
input::placeholder,
textarea::placeholder {
color: #888888;
}
@theme {
--color-primary: #000000;
--color-secondary: #eaecf4;
--color-border: #d0d0d0;
--color-description: #888888;
--radius-2.5: 10px;
}
.rmdp-input {
min-height: 40px;
background-color: white;
border-radius: 12px !important;
border: 1px solid #d0d0d0 !important;
font-size: 12px !important;
width: 100% !important;
margin: 0px;
padding-right: 16px !important;
}
.readOny .rmdp-input {
background-color: #f5f5f5 !important;
border: none !important;
}
.rmdp-container {
width: 100%;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
.ql-container {
@apply rounded-b-xl text-right;
font-family: irancell !important;
direction: rtl;
min-height: 100px !important;
}
.ql-toolbar {
@apply rounded-t-xl;
}
.ql-editor {
@apply !text-right;
}
.dltr {
direction: ltr;
}
/* Start Generation Here */
/* Hide scrollbar for all elements */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
/* Hide scrollbar for WebKit browsers (Chrome, Safari, Edge) */
*::-webkit-scrollbar {
display: none;
}
/* End Generation Here */
/* انیمیشن برای فیلترهای موبایل */
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeInDown {
animation: fadeInDown 0.3s ease-out;
}
.ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg {
display: none;
}
+165
View File
@@ -0,0 +1,165 @@
{
"sidebar": {
"menu": "منو",
"received": "دریافتی ها",
"sent": "ارسال شده ها",
"draft": "پیش نویس ها",
"archive": "آرشیو شده ها",
"profile": "پروفایل",
"trash": "سطل زباله",
"other": "سایر",
"favorite": "نشان شده ها",
"spam": "هرزنامه ها",
"setting": "تنظیمات",
"logout": "خروج",
"admin_panel": "پنل مدیریت"
},
"header": {
"search": "جستجو"
},
"errors": {
"required": "این فیلد اجباری است"
},
"received": {
"title": "دریافتی ها",
"from_date": "از تاریخ",
"to_date": "تا تاریخ",
"all": "همه",
"read": "خوانده شده",
"unread": "خوانده نشده",
"search": "جستجو"
},
"sent": {
"title": "ارسال شده ها",
"from_date": "از تاریخ",
"to_date": "تا تاریخ",
"all": "همه",
"read": "خوانده شده",
"unread": "خوانده نشده",
"search": "جستجو"
},
"draft": {
"title": "پیش نویس ها",
"from_date": "از تاریخ",
"to_date": "تا تاریخ",
"search": "جستجو"
},
"archive": {
"title": "آرشیو شده ها",
"from_date": "از تاریخ",
"to_date": "تا تاریخ",
"all": "همه",
"read": "خوانده شده",
"unread": "خوانده نشده",
"search": "جستجو"
},
"trash": {
"title": "سطل زباله",
"from_date": "از تاریخ",
"to_date": "تا تاریخ",
"all": "همه",
"read": "خوانده شده",
"unread": "خوانده نشده",
"search": "جستجو"
},
"setting": {
"title": "تنظیمات",
"personality": "شخصی سازی",
"mail_server": "تنظیمات میل سرور",
"domain": "تنظیمات دامنه",
"address": "نشانی ها",
"setting": "تنظیمات",
"background": "رنگ زمینه",
"upload_background": "تصویر پس زمینه مورد نظر را آپلود کنید",
"size": "سایز",
"horizontal_position": "موقعیت افقی",
"vertical_position": "موقعیت عمودی",
"text": "متن",
"add_text": "اضافه کردن متن",
"button": "دکمه",
"button_text": "متن دکمه",
"button_link": "لینک",
"select": "انتخاب",
"border": "حاشیه",
"text_color": "رنگ متن",
"background_color": "رنگ زمینه",
"border_color": "رنگ حاشیه",
"add_button": "اضافه کردن دکمه",
"image": "تصویر",
"upload_image": "تصویر مورد نظر را آپلود کنید",
"add_image": "اضافه کردن تصویر",
"setting_fetch": "تنظیمات فچ",
"setting_fetch_description": "تنظیمات فچ خود را با دقت انتخاب کنید.",
"setting_smtp": "تنظیمات SMTP",
"setting_smtp_description": "تنظیمات SMTP خود را با دقت وارد کنید.",
"smtp_server": "SMTP سرور",
"smtp_port": "SMTP پورت",
"username": "نام کاربری",
"password": "پسورد",
"record_dns": "رکوردهای DNS",
"record_dns_description": "در این قسمت میتوانید وضعیت رکوردهای DNS سرویس ایمیل خود را بررسی کنید.",
"your_domain": "دامنه شما",
"submit": "ثبت",
"status": "وضعیت",
"search": "جستجو",
"address_title": "عنوان نشانی",
"email": "ایمیل",
"marketing": "بازاریابی",
"actions": "عملیات",
"delete": "حذف کردن ایمیل",
"active": "فعال",
"inactive": "غیرفعال",
"add_address": "اضافه کردن نشانی",
"domain1": "دامنه",
"create": "ساخت",
"header_email": "هدر ایمیل شما",
"content_email": "محتوای ایمیل شما",
"footer_email": "پاورقی ایمیل شما",
"signature": "امضا",
"signature_description": "تصویر و توضیحات امضای خود را با دقت وارد کنید",
"upload_sign": "آپلود امضا",
"description_sign": "توضیحات امضا",
"select_section": "لطفا یک بخش انتخاب کنید",
"title1": "عنوان",
"quota": "فضا/ استفاده شده "
},
"attachment_select": "اضافه کردن فایل ضمیمه",
"domain": {
"table": {
"record_type": "نوع رکورد",
"record_name": "نام رکورد",
"record_value": "مقدار رکورد",
"priority": "اولویت",
"ttl": "TTL",
"status": "وضعیت"
},
"status": {
"verified": "تایید شده",
"pending": "در انتظار",
"failed": "ناموفق"
}
},
"select_file": "انتخاب فایل",
"upload": "آپلود",
"new_message": {
"title": "ارسال پیام جدید",
"to": "ارسال به",
"subject": "موضوع",
"message": "پیام",
"send": "ارسال",
"draft": "پیش نویس",
"recipients_placeholder": "نام گیرنده",
"subject_placeholder": "موضوع پیام",
"message_placeholder": "متن پیام",
"select_priority": "انتخاب اولویت"
},
"mail": {
"download": "دانلود",
"print": "چاپ",
"subject": "موضوع: ",
"attachments": "فایل های ضمیمه",
"answer": "پاسخ",
"forward": "ارسال به دیگران",
"message": "متن پبام"
}
}
+6
View File
@@ -0,0 +1,6 @@
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>,
)
+7
View File
@@ -0,0 +1,7 @@
import axiosDanak from "@/config/axiosDanak";
import { RefreshTokenType } from "../types/Types";
export const refreshToken = async (params: RefreshTokenType) => {
const { data } = await axiosDanak.post(`/auth/refresh`, params);
return data;
};
+3
View File
@@ -0,0 +1,3 @@
export type RefreshTokenType = {
refreshToken: string;
};
View File
+83
View File
@@ -0,0 +1,83 @@
import { FC, useEffect, useState } from 'react'
import { useWorkspaces } from './hooks/useHomeData'
import { workspaceItem } from './types/HomeTypes'
import moment from 'moment-jalaali'
const Home: FC = () => {
const getWorkspaces = useWorkspaces()
const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(null)
useEffect(() => {
setSelectedWorkspace(localStorage.getItem(import.meta.env.VITE_WORKSPACE_ID))
}, [])
useEffect(() => {
if (getWorkspaces.isError && getWorkspaces.error instanceof Error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error = getWorkspaces.error as any;
if (error.response?.status === 403) {
localStorage.removeItem(import.meta.env.VITE_WORKSPACE_ID)
window.location.href = 'https://console.danakcorp.com'
}
}
}, [getWorkspaces.isError, getWorkspaces.data])
const handleChangeWorkspace = (id: string) => {
localStorage.setItem(import.meta.env.VITE_WORKSPACE_ID, id)
setSelectedWorkspace(id)
window.location.reload()
}
return (
<div className='w-full flex gap-6'>
<div className='flex-1'>
<div className='mt-8 flex-wrap text-sm flex gap-6 items-center'>
{
getWorkspaces.data?.data?.workspaces?.map((item: workspaceItem) => {
return (
<div className={`p-6 min-w-[40%] xl:min-w-[15%] flex flex-col items-center xl:items-start flex-1 bg-white rounded-3xl cursor-pointer transition-all duration-300 hover:shadow-lg ${selectedWorkspace === item.id ? 'border border-black' : ''}`}
onClick={() => handleChangeWorkspace(item.id)}
>
<div className='mt-5'>
{item.businessName}
</div>
<div className='mt-5'>
{item.plan?.name}
</div>
<div className='flex justify-between items-center mt-5 w-full'>
<div className=' flex gap-1'>
<div>تاریخ پایان: </div>
<div className='text-description text-sm'>{moment(item?.endDate).format('jYYYY/jMM/jDD')}</div>
</div>
</div>
<div className='mt-5 flex justify-end w-full'>
<a onClick={(e) => {
e.stopPropagation()
}} target='_blank' href={`${import.meta.env.VITE_DZONE_URL}/${item.slug}`}>
{`${import.meta.env.VITE_DZONE_URL}/${item.slug}`}
</a>
</div>
</div>
)
})
}
<div className='px-6 min-w-[40%] xl:min-w-[15%] flex flex-col items-center xl:items-start flex-1 h-[178px]'></div>
</div>
</div>
</div>
)
}
export default Home
@@ -0,0 +1,46 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowLeft } from 'iconsax-react'
import CoverImage from '../../../assets/images/banner.png'
const DanakLearning: FC = () => {
const { t } = useTranslation('global')
return (
<div className='bg-white w-sidebar text-xs hidden 2xl:block h-fit px-5 py-7 rounded-3xl'>
<div className='flex justify-between items-center'>
<div>
{t('home.danak_learning')}
</div>
<div className='flex gap-2 items-center text-[10px]'>
<div>{t('home.see_all')}</div>
<ArrowLeft size={12} color='black' />
</div>
</div>
<div className='mt-7'>
<div className='flex gap-3'>
<div>
<img src={CoverImage} alt='danak-learning-1' className='w-36 h-24 object-cover rounded-2xl' />
</div>
<div className='text-xs'>
<div className='leading-5'>
لورم ایپسوم متن ساختگی با تولید سادگی
</div>
<div className='flex gap-1 text-[11px] mt-3 text-description'>
<div>دیزاین ,</div>
<div>۲۴ دقیقه</div>
</div>
<div className='text-description text-[11px] mt-2'>
آذر ماه 1403
</div>
</div>
</div>
</div>
</div>
)
}
export default DanakLearning
@@ -0,0 +1,37 @@
import { FC, ReactNode } from 'react'
import { Link } from 'react-router-dom'
type Props = {
title: string
icon: ReactNode,
color: string,
count: number,
description: string,
to?: string
}
const ItemDashboard: FC<Props> = (props: Props) => {
return (
<Link to={props.to ? props.to : ''} className='p-6 min-w-[40%] xl:min-w-[15%] flex flex-col items-center xl:items-start flex-1 h-[178px] bg-white rounded-3xl'>
<div className='size-10 rounded-full bg-[#EEF0F7] flex justify-center items-center'>
{props.icon}
</div>
<div className='mt-5'>
{props.title}
</div>
<div className='mt-5 text-xs text-description flex gap-1.5 items-center'>
<div style={{ background: props.color }} className='size-1.5 rounded-full'></div>
<div className='flex gap-0.5'>
<div>{props.count}</div>
<div className='whitespace-nowrap'>
{props.description}
</div>
</div>
</div>
</Link>
)
}
export default ItemDashboard
+9
View File
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import * as api from "../service/HomeService";
export const useWorkspaces = () => {
return useQuery({
queryKey: ["workspaces"],
queryFn: api.getWorkspaces,
});
};
+8
View File
@@ -0,0 +1,8 @@
import danakAxios from "../../../config/axiosDanak";
export const getWorkspaces = async () => {
const { data } = await danakAxios.get(
`/subscriptions/workspaces/${import.meta.env.VITE_SERVICE_ID}`
);
return data;
};
+48
View File
@@ -0,0 +1,48 @@
import { UserItemType } from "../../users/types/UserTypes";
export type workspaceItem = {
id: string;
createdAt: string;
updatedAt: string;
plan: {
id: string;
createdAt: string;
updatedAt: string;
name: string;
duration: number;
price: number;
originalPrice: number;
isActive: boolean;
service: {
id: string;
createdAt: string;
updatedAt: string;
name: string;
title: string;
slug: string;
isDanakSuggest: boolean;
description: string;
author: string;
createDate: string;
userCount: number;
serviceLanguage: string;
softwareLanguage: string;
metaDescription: string;
isActive: boolean;
showInSlider: boolean;
link: string;
icon: string;
coverUrl: string;
deletedAt: string | null;
};
deletedAt: string | null;
};
startDate: string;
endDate: string;
businessName: string;
businessPhone: string;
description: string;
slug: string;
status: string;
staff: UserItemType[];
};
+81
View File
@@ -0,0 +1,81 @@
import { FC, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import { SettingTabEnum } from './enum/SettingEnum'
import Personality from './personality/Personality'
import Domain from './domain/Domain'
import MailServer from './mail-server/MailServer'
import Address from './address/Address'
import PersonalitySidebar from './personality/SideBar'
import Signture from './signture/Signture'
import { Paths } from '@/utils/Paths'
const Setting: FC = () => {
const { t } = useTranslation()
const location = useLocation()
const [activeTab, setActiveTab] = useState<SettingTabEnum>(SettingTabEnum.SETTING_DOMAIN)
// Map URL paths to setting tabs
const getTabFromPath = (pathname: string): SettingTabEnum => {
switch (pathname) {
case Paths.settingMailServer:
return SettingTabEnum.SETTING_MAIL_SERVER
case Paths.settingDomain:
return SettingTabEnum.SETTING_DOMAIN
case Paths.settingAddress:
return SettingTabEnum.SETTING_ADDRESS
case Paths.settingPersonality:
return SettingTabEnum.SETTING_PERSONALITY
case Paths.settingSignature:
return SettingTabEnum.SETTING_SIGNATURE
default:
return SettingTabEnum.SETTING_DOMAIN
}
}
// Map setting tabs to URL paths
// Update active tab when URL changes
useEffect(() => {
const tabFromPath = getTabFromPath(location.pathname)
setActiveTab(tabFromPath)
}, [location.pathname])
const renderActiveTab = () => {
switch (activeTab) {
case SettingTabEnum.SETTING_MAIL_SERVER:
return <MailServer />;
case SettingTabEnum.SETTING_DOMAIN:
return <Domain />;
case SettingTabEnum.SETTING_ADDRESS:
return <Address />;
case SettingTabEnum.SETTING_SIGNATURE:
return <Signture />;
case SettingTabEnum.SETTING_PERSONALITY:
return (
<div className='flex xl:flex-row-reverse gap-2 md:gap-6 mt-6 md:mt-8'>
<div className="xl:w-auto">
<PersonalitySidebar />
</div>
<div className="flex-1">
<Personality />
</div>
</div>
);
default:
return null;
}
};
return (
<div className='mt-2 md:mt-4 px-2 md:px-0'>
<h1 className='text-lg mb-4 md:mb-0'>{t('setting.title')}</h1>
<div>
{renderActiveTab()}
</div>
</div>
)
}
export default Setting
+218
View File
@@ -0,0 +1,218 @@
import Input from '@/components/Input'
import Select from '@/components/Select'
import Table from '@/components/Table'
import { ColumnType } from '@/components/types/TableTypes'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/components/Button'
import { TickCircle } from 'iconsax-react'
import { useForm } from 'react-hook-form'
import { AddressData, CreateAddressType } from './types/Types'
import { useGetDomains } from '../domain/hooks/useDomainData'
import { yupResolver } from "@hookform/resolvers/yup"
import * as yup from "yup"
import { useCreateAddress, useGetAddress } from './hooks/useAddressData'
import { toast } from '@/components/Toast'
import { ErrorType } from '@/helpers/types'
import ProgressBarEmail from './components/ProgressBarEmail'
import ToggleStatus from './components/ToggleStatus'
const Address: FC = () => {
const { t } = useTranslation()
const schema = yup.object().shape({
title: yup.string().required(t('errors.required')),
username: yup.string().required(t('errors.required')),
password: yup.string().required(t('errors.required')),
domainId: yup.string().required(t('errors.required')),
})
const [search, setSearch] = useState<string>('')
const [status, setStatus] = useState<string>('all')
const { data: domain } = useGetDomains()
const { mutate: createAddress, isPending } = useCreateAddress()
const { data: addresses, isPending: isPendingAddress } = useGetAddress(search, status)
const { register, handleSubmit, formState: { errors },
} = useForm<CreateAddressType>({
resolver: yupResolver(schema),
defaultValues: {
domainId: domain?.data?.domain?.id || '',
}
})
const columns: ColumnType<AddressData>[] = [
{
title: t('setting.address_title'),
key: 'title',
width: '200px'
},
{
title: t('setting.email'),
key: 'email',
width: '300px',
render: (item: AddressData) => (
<div key={item.id} className='dltr flex justify-end'>
{item.emailAddress}
</div>
)
},
{
title: t('setting.quota'),
key: 'quota',
width: '300px',
render: (item: AddressData) => {
return (
<ProgressBarEmail key={item.id} item={item} />
)
}
},
{
title: t('setting.status'),
key: 'status',
width: '120px',
align: 'center',
render: (item: AddressData) => (
<div key={item.id} className='dltr flex justify-end'>
<ToggleStatus defaultStatus={item.isActive} id={item.id} />
</div>
)
}
]
const statusOptions = [
{ value: 'all', label: 'همه' },
{ value: 'active', label: t('setting.active') },
{ value: 'inactive', label: t('setting.inactive') }
]
const onSubmit = (params: CreateAddressType) => {
createAddress(params, {
onSuccess: (data) => {
toast(data?.data?.message, 'success')
},
onError: (error: ErrorType) => {
toast(error.response?.data?.error?.message[0], 'error')
}
})
}
return (
<div className='flex xl:flex-row flex-col-reverse gap-8 mt-8'>
<div className='flex-1'>
<div className='flex gap-2 justify-between'>
<div>
<Select
placeholder={t('setting.status')}
items={statusOptions}
className='w-[120px]'
onChange={(e) => setStatus(e.target.value)}
/>
</div>
<div className='flex-1'>
<Input
variant='search'
placeholder={t('setting.search')}
className='xl:w-[300px] flex-1'
onChangeSearchFinal={(e) => setSearch(e)}
/>
</div>
</div>
<Table
isLoading={isPendingAddress}
columns={columns}
data={addresses?.data?.users}
pagination={{
currentPage: 1,
totalPages: 6,
onPageChange: (page) => console.log('Page:', page)
}}
/>
</div>
<div className='xl:w-[350px] w-full bg-white rounded-4xl p-8'>
<div>
{t('setting.add_address')}
</div>
{/* <div className='mt-8 flex items-center justify-between'>
<div>
{t('setting.status')}
</div>
<div className='dltr pt-2'>
<Switch />
</div>
</div> */}
<div className='mt-6'>
<Input
label={t('setting.domain1')}
value={domain?.data?.domain?.name}
readOnly
/>
</div>
<div className='mt-6'>
<Input
label={t('setting.title1')}
{...register('title')}
error_text={errors.title?.message}
/>
</div>
<div className='mt-6 relative'>
<Input
label={t('setting.username')}
className='text-left'
{...register('username')}
error_text={errors.username?.message}
/>
<div className='absolute flex items-center dltr text-xs z-10 right-[1px] top-[29px] rounded-r-2.5 h-[38px] bg-[#EBEEF5] px-3'>
@ {domain?.data?.domain?.name}
</div>
</div>
<div className='mt-6'>
<Input
label={t('setting.password')}
type='password'
{...register('password')}
error_text={errors.password?.message}
/>
</div>
<div className='mt-2 text-xs'>
<div>رمز عبور میبایست:</div>
<ul className='list-disc pr-3 mt-1 flex flex-col gap-1'>
<li>حداقل ۸ کاراکتر باشد</li>
<li>ترکیبی از حروف کوچک و بزرگ باشد</li>
<li>شامل اعداد باشد</li>
<li>شامل کاراکتر های خاص (نماد ها) باشد</li>
</ul>
</div>
<div className='mt-9 flex justify-end'>
<Button
className='w-fit px-20'
onClick={handleSubmit(onSubmit)}
loading={isPending}
>
<div className='flex gap-2'>
<TickCircle size={20} color='white' />
<div>
{t('setting.create')}
</div>
</div>
</Button>
</div>
</div>
</div>
)
}
export default Address
@@ -0,0 +1,33 @@
import { convertToGB } from '@/config/func'
import { FC } from 'react'
import { AddressData } from '../types/Types'
const ProgressBarEmail: FC<{ item: AddressData }> = ({ item }) => {
const usedQuota = item.emailQuotaUsed || 0;
const totalQuota = item.emailQuota || 0;
const percentage = Math.min((usedQuota / totalQuota) * 100, 100);
return (
<div className='flex flex-col gap-2'>
<div className='flex justify-between text-sm'>
<span className='text-gray-600 dltr'>
{convertToGB(usedQuota)} GB / {convertToGB(totalQuota)} GB
</span>
{/* <span className='text-gray-500'>
{percentage.toFixed(1)}%
</span> */}
</div>
<div className='w-full bg-gray-200 dltr overflow-hidden rounded-full h-2'>
<div
className={`h-2 rounded-full transition-all duration-300 ${percentage > 90 ? 'bg-red-500' :
percentage > 70 ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
)
}
export default ProgressBarEmail
@@ -0,0 +1,18 @@
import { Switch } from '@/components/ui/switch'
import { FC, useState } from 'react'
import { useToggleStatus } from '../hooks/useAddressData'
const ToggleStatus: FC<{ defaultStatus: boolean, id: string }> = ({ defaultStatus, id }) => {
const [isActive, setIsActive] = useState(defaultStatus)
const { mutate: toggleStatus } = useToggleStatus()
const handleToggleStatus = () => {
toggleStatus(id)
setIsActive(!isActive)
}
return (
<Switch checked={isActive} onCheckedChange={handleToggleStatus} />
)
}
export default ToggleStatus
@@ -0,0 +1,26 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import * as api from "../service/Service";
import { useQueryClient } from "@tanstack/react-query";
export const useGetAddress = (search: string, status: string) => {
return useQuery({
queryKey: ["address", search, status],
queryFn: () => api.getAddress(search, status),
});
};
export const useCreateAddress = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createAddress,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["address"] });
},
});
};
export const useToggleStatus = () => {
return useMutation({
mutationFn: api.toggleStatus,
});
};
@@ -0,0 +1,21 @@
import axios from "@/config/axios";
import { CreateAddressType } from "../types/Types";
export const getAddress = async (search: string, status: string) => {
const query = new URLSearchParams();
if (search) query.set("q", search);
if (status && status !== "all")
query.set("isActive", status === "active" ? "1" : "0");
const { data } = await axios.get(`/users?${query.toString()}`);
return data;
};
export const createAddress = async (params: CreateAddressType) => {
const { data } = await axios.post(`/users`, params);
return data;
};
export const toggleStatus = async (id: string) => {
const { data } = await axios.patch(`/users/${id}/toggle-status`);
return data;
};
+58
View File
@@ -0,0 +1,58 @@
export type CreateAddressType = {
username: string;
password: string;
domainId: string;
title: string;
};
export interface AddressData extends Record<string, unknown> {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
title: string;
userName: string;
password: string;
emailAddress: string;
emailEnabled: boolean;
emailQuota: number;
emailQuotaUsed: number;
emailAccountId: string;
displayName: string;
isActive: boolean;
emailSignature: string | null;
role: string;
business: {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
danakSubscriptionId: string;
name: string;
slug: string;
logoUrl: string | null;
domain: string;
};
domain: {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
status: string;
isVerified: boolean;
isActive: boolean;
verifiedAt: string;
lastCheckedAt: string | null;
notes: string;
dkimEnabled: boolean;
dkimSelector: string;
dkimPrivateKey: string;
dkimPublicKey: string;
spfEnabled: boolean;
spfRecord: string | null;
dmarcEnabled: boolean;
dmarcPolicy: string | null;
business: string;
};
}
+156
View File
@@ -0,0 +1,156 @@
import Table from '@/components/Table'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TickCircle, Copy, CloseCircle, Clock, TickSquare } from 'iconsax-react'
import CreateDomain from './components/CreateDomain'
import { useGetDnsRecords } from './hooks/useDomainData'
import { DnsRecordType } from './types/Types'
const Domain: FC = () => {
const { t } = useTranslation()
const [disabled, setDisabled] = useState(false)
const { data, isLoading } = useGetDnsRecords(disabled)
const [copiedField, setCopiedField] = useState<string | null>(null)
useEffect(() => {
if (data?.data?.overallStatus?.isVerified && !disabled) {
setDisabled(true)
}
}, [data])
const getStatusIcon = (status: DnsRecordType['status']) => {
switch (status) {
case 'VERIFIED':
return (
<div className="flex items-center gap-2">
<TickCircle size={20} color="#22C55E" variant="Bold" />
<span className="text-green-500 text-xs">{t('domain.status.verified')}</span>
</div>
)
case 'PENDING':
return (
<div className="flex items-center gap-2">
<Clock size={20} color="#F59E0B" variant="Bold" />
<span className="text-yellow-500 text-xs">{t('domain.status.pending')}</span>
</div>
)
case 'FAILED':
return (
<div className="flex items-center gap-2">
<CloseCircle size={20} color="#EF4444" variant="Bold" />
<span className="text-red-500 text-xs">{t('domain.status.failed')}</span>
</div>
)
default:
return null
}
}
const copyToClipboard = (text: string, fieldKey: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedField(fieldKey)
setTimeout(() => {
setCopiedField(null)
}, 2000)
})
}
const columns = [
{
title: t('domain.table.record_type'),
key: 'type',
width: '100px',
align: 'center' as const,
},
{
title: t('domain.table.record_name'),
key: 'name',
render: (record: DnsRecordType) => {
const fieldKey = `name-${record.id}`
const isCopied = copiedField === fieldKey
return (
<div className="flex items-center gap-2">
<button
onClick={() => copyToClipboard(record.name, fieldKey)}
className={`transition-all duration-200 ${isCopied ? 'text-green-500' : 'text-blue-500 hover:text-blue-700'}`}
>
{isCopied ? (
<TickSquare size={16} color='#22C55E' variant="Bold" />
) : (
<Copy size={16} color='#0038FF' />
)}
</button>
<span className="truncate max-w-[200px] dltr">{record.name}</span>
</div>
)
}
},
{
title: t('domain.table.record_value'),
key: 'value',
render: (record: DnsRecordType) => {
const fieldKey = `value-${record.id}`
const isCopied = copiedField === fieldKey
return (
<div className="flex items-center gap-2">
<button
onClick={() => copyToClipboard(record.value, fieldKey)}
className={`transition-all duration-200 ${isCopied ? 'text-green-500' : 'text-blue-500 hover:text-blue-700'}`}
>
{isCopied ? (
<TickSquare size={16} color='#22C55E' variant="Bold" />
) : (
<Copy size={16} color='#0038FF' />
)}
</button>
<span className="truncate max-w-[300px] dltr">{record.value}</span>
</div>
)
}
},
{
title: t('domain.table.priority'),
key: 'priority',
width: '80px',
align: 'center' as const,
render: (record: DnsRecordType) => (
<span>{record.priority || '-'}</span>
)
},
{
title: t('domain.table.ttl'),
key: 'ttl',
width: '80px',
align: 'center' as const,
render: (record: DnsRecordType) => (
<span>{record.ttl}</span>
)
},
{
title: t('domain.table.status'),
key: 'status',
render: (record: DnsRecordType) => getStatusIcon(record.status)
},
]
const dnsRecords = data?.data?.dnsRecords || []
return (
<div>
<CreateDomain />
<div className='mt-9'>
<Table
columns={columns}
data={dnsRecords}
className="!mt-0"
isLoading={isLoading}
/>
</div>
</div>
)
}
export default Domain
@@ -0,0 +1,76 @@
import Button from '@/components/Button'
import Input from '@/components/Input'
import { toast } from '@/components/Toast'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useCreateDomain, useGetDomains } from '../hooks/useDomainData'
import { ErrorType } from '@/helpers/types'
const CreateDomain: FC = () => {
const { t } = useTranslation()
const { mutate: createDomain, isPending } = useCreateDomain()
const { data: domains } = useGetDomains()
const [domain, setDomain] = useState('')
const [isVerified, setIsVerified] = useState<boolean>(false)
useEffect(() => {
if (domains?.data?.domain) {
setDomain(domains?.data?.domain?.name)
if (domains?.data?.domain?.isVerified) {
setIsVerified(true)
}
}
}, [domains])
const handleCreateDomain = () => {
createDomain({
name: domain,
notes: domain
}, {
onSuccess: (data) => {
toast(data.message, 'success')
setDomain('')
},
onError: (error: ErrorType) => {
toast(error.response?.data?.error?.message[0], 'error')
}
})
}
return (
<div className='flex xl:flex-row gap-4 flex-col justify-between mt-9 items-end'>
<div>
<div className='xl:text-lg'>
{t('setting.record_dns')}
</div>
<p className='mt-2 xl:text-sm text-xs font-extralight'>
{t('setting.record_dns_description')}
</p>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('setting.your_domain')}
className='xl:w-[300px]'
value={domain}
onChange={(e) => setDomain(e.target.value)}
readOnly={isVerified}
/>
{
!isVerified &&
<Button
variant='secondary'
className='w-fit xl:px-14 px-8 border border-black'
label={t('setting.submit')}
onClick={handleCreateDomain}
loading={isPending}
/>
}
</div>
</div>
)
}
export default CreateDomain
@@ -0,0 +1,32 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreateDomainType } from "../types/Types";
import * as api from "../service/DomainService";
export const useCreateDomain = () => {
return useMutation({
mutationFn: (variables: CreateDomainType) => api.createDomain(variables),
});
};
export const useGetDomains = () => {
return useQuery({
queryKey: ["domains"],
queryFn: () => api.getDomains(),
});
};
export const useGetDnsRecords = (disabled?: boolean) => {
return useQuery({
queryKey: ["dns-records"],
queryFn: () => api.getDnsRecords(),
refetchInterval: 3000,
enabled: !disabled,
});
};
export const useVerifyDnsRecord = () => {
return useQuery({
queryKey: ["verify-dns-record"],
queryFn: () => api.verifyDnsRecord(),
});
};
@@ -0,0 +1,22 @@
import axios from "@/config/axios";
import { CreateDomainType } from "../types/Types";
export const createDomain = async (params: CreateDomainType) => {
const { data } = await axios.post(`/domains`, params);
return data;
};
export const getDomains = async () => {
const { data } = await axios.get(`/domains`);
return data;
};
export const getDnsRecords = async () => {
const { data } = await axios.get(`/domains/dns-records`);
return data;
};
export const verifyDnsRecord = async () => {
const { data } = await axios.get(`/domains/verify-dns`);
return data;
};
+33
View File
@@ -0,0 +1,33 @@
import { IResponse } from "@/types/response.types";
export type CreateDomainType = {
name: string;
notes?: string;
};
export type DnsRecordType = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
wildduckId: string | null;
name: string;
type: "MX" | "SPF" | "DMARC" | "DKIM" | "TXT" | "A" | "AAAA" | "CNAME";
value: string;
ttl: number;
priority: number | null;
status: "PENDING" | "VERIFIED" | "FAILED";
isRequired: boolean;
isActive: boolean;
lastVerifiedAt: string | null;
lastCheckedAt: string | null;
description: string;
errorMessage: string | null;
verificationAttempts: number;
nextVerificationAt: string | null;
domain: string;
};
export type DnsRecordResponseType = IResponse<{
dnsRecords: DnsRecordType[];
}>;
+15
View File
@@ -0,0 +1,15 @@
export enum SettingTabEnum {
SETTING_MAIL_SERVER = "setting_mail_server",
SETTING_DOMAIN = "setting_domain",
SETTING_ADDRESS = "setting_address",
SETTING_PERSONALITY = "setting_personality",
SETTING_SIGNATURE = "setting_signature",
}
export enum SideBarTab {
SETTING = "setting",
TEXT = "text",
BUTTON = "button",
IMAGE = "image",
NONE = "none",
}
@@ -0,0 +1,79 @@
import Input from '@/components/Input'
import RadioGroup from '@/components/RadioGroup'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
const Domain: FC = () => {
const { t } = useTranslation()
return (
<div className='bg-white rounded-4xl p-8 mt-9'>
<div>
<div className='flex xl:flex-row gap-4 flex-col justify-between xl:items-end border-b pb-10 broder-border'>
<div className='flex-1'>
<div className='xl:text-lg'>
{t('setting.setting_fetch')}
</div>
<div className='xl:text-sm text-xs text-description mt-1.5'>
{t('setting.setting_fetch_description')}
</div>
</div>
<div className='flex-1'>
<RadioGroup
items={[
{
label: '۱ دقیقه',
value: '1'
},
{
label: '۲ دقیقه',
value: '2'
},
{
label: '۳ دقیقه',
value: '3'
}
]}
onChange={() => null}
selected='1'
/>
</div>
</div>
</div>
<div className='mt-9'>
<div className='flex xl:flex-row gap-4 flex-col justify-between pb-10'>
<div className='flex-1'>
<div className='xl:text-lg'>
{t('setting.setting_smtp')}
</div>
<div className='xl:text-sm text-xs text-description mt-1.5'>
{t('setting.setting_smtp_description')}
</div>
</div>
<div className='flex-1'>
<Input
label={t('setting.smtp_server')}
/>
<div className='mt-8'>
<Input
label={t('setting.smtp_port')}
/>
</div>
<div className='mt-8'>
<Input
label={t('setting.username')}
/>
</div>
<div className='mt-8'>
<Input
label={t('setting.password')}
/>
</div>
</div>
</div>
</div>
</div>
)
}
export default Domain
@@ -0,0 +1,20 @@
import { FC } from 'react'
import HeaderSection from './components/HeaderSection'
import ContentSection from './components/ContentSection'
import FooterSection from './components/FooterSection'
const Personality: FC = () => {
return (
<div className='flex-1 bg-white rounded-4xl p-8'>
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ borderCollapse: 'collapse' }}>
<tbody>
<HeaderSection />
<ContentSection />
<FooterSection />
</tbody>
</table>
</div>
)
}
export default Personality
+67
View File
@@ -0,0 +1,67 @@
import { FC, useState, useRef, useEffect } from 'react'
import { Gallery, LinkSquare, Setting4, Text } from 'iconsax-react'
import Logo from '@/assets/images/logo-small.svg'
import SettingSideBar from './components/SettingSideBar'
import { SideBarTab } from '../enum/SettingEnum'
import TextSidebar from './components/TextSidebar'
import ButtonSidebar from './components/ButtonSidebar'
import ImageSideBar from './components/ImageSideBar'
import { clx } from '@/helpers/utils'
const PersonalitySidebar: FC = () => {
const [active, setActive] = useState<SideBarTab>(SideBarTab.SETTING)
const sidebarRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// فقط در موبایل (عرض کمتر از 1280px) کار کند
if (window.innerWidth < 1280 && sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
setActive(SideBarTab.NONE)
}
}
if (active !== SideBarTab.NONE) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [active])
return (
<div ref={sidebarRef} className={clx(
'bg-white xl:rounded-4xl xl:overflow-hidden xl:w-[360px] flex h-full sticky top-0',
active === SideBarTab.NONE ? 'rounded-4xl' : 'rounded-r-4xl'
)}>
{active !== SideBarTab.NONE && (
<div className='absolute rounded-l-4xl shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.05)] xl:shadow-none xl:static bg-white right-10 h-full z-10 xl:w-[360px] w-[250px] p-6 overflow-y-auto'>
{
active === SideBarTab.SETTING ? <SettingSideBar />
: active === SideBarTab.TEXT ? <TextSidebar />
: active === SideBarTab.BUTTON ? <ButtonSidebar />
: active === SideBarTab.IMAGE ? <ImageSideBar />
: null
}
</div>
)}
<div className='xl:w-20 xl:border-r py-5 border-gray-200 w-10 flex flex-col items-center pt-6 pb-6 h-full'>
{
active !== SideBarTab.NONE && <img src={Logo} className='w-8' alt="Logo" />
}
<div className={clx(
'flex flex-col gap-10 mt-16',
active === SideBarTab.NONE && 'mt-4'
)}>
<Setting4 size={22} color='black' variant={SideBarTab.SETTING === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.SETTING)} className='cursor-pointer' />
<Text size={22} color='black' variant={SideBarTab.TEXT === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.TEXT)} className='cursor-pointer' />
<LinkSquare size={22} color='black' variant={SideBarTab.BUTTON === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.BUTTON)} className='cursor-pointer' />
<Gallery size={22} color='black' variant={SideBarTab.IMAGE === active ? 'Bold' : 'Outline'} onClick={() => setActive(SideBarTab.IMAGE)} className='cursor-pointer' />
</div>
</div>
</div>
)
}
export default PersonalitySidebar
@@ -0,0 +1,94 @@
import { FC } from 'react'
import { ButtonType, ButtonSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
interface ButtonRendererProps {
buttons: ButtonType[]
}
const ButtonRenderer: FC<ButtonRendererProps> = ({ buttons }) => {
if (!buttons || buttons.length === 0) return null
// تبدیل enum به string برای HTML attributes
const getHorizontalAlign = (alignment?: HorizontalAlignment) => {
switch (alignment) {
case HorizontalAlignment.LEFT:
return 'left'
case HorizontalAlignment.RIGHT:
return 'right'
case HorizontalAlignment.CENTER:
default:
return 'center'
}
}
const getVerticalAlign = (verticalAlignment?: VerticalAlignment) => {
switch (verticalAlignment) {
case VerticalAlignment.TOP:
return 'top'
case VerticalAlignment.BOTTOM:
return 'bottom'
case VerticalAlignment.MIDDLE:
default:
return 'middle'
}
}
return (
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ borderCollapse: 'collapse' }}>
<tbody>
<tr>
<td
align={getHorizontalAlign(buttons[0]?.alignment)}
valign={getVerticalAlign(buttons[0]?.verticalAlignment)}
style={{ width: '100%' }}
>
<table cellPadding="0" cellSpacing="0" border={0} style={{ borderCollapse: 'collapse' }}>
<tbody>
<tr>
{buttons.slice(0, 2).map((button, buttonIndex) => (
<td key={button.id} style={{ paddingRight: buttonIndex === 0 && buttons.length > 1 ? '4px' : '0' }}>
<table cellPadding="0" cellSpacing="0" border={0} style={{
borderCollapse: 'collapse',
backgroundColor: button.backgroundColor,
border: button.isBorder ? `${button.borderSize}px solid ${button.borderColor}` : 'none',
borderRadius: '4px'
}}>
<tbody>
<tr>
<td
align="center"
valign="middle"
style={{
padding: button.size === ButtonSize.SMALL ? '2px 4px' : button.size === ButtonSize.MEDIUM ? '4px 8px' : '8px 12px',
fontSize: button.size === ButtonSize.SMALL ? '11px' : button.size === ButtonSize.MEDIUM ? '13px' : '15px',
color: button.textColor,
textDecoration: 'none',
whiteSpace: 'nowrap',
maxWidth: 'fit-content',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
<a href={button.link || '#'} style={{
color: button.textColor,
textDecoration: 'none',
}}>
{button.text}
</a>
</td>
</tr>
</tbody>
</table>
</td>
))}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
)
}
export default ButtonRenderer
@@ -0,0 +1,200 @@
import Button from '@/components/Button'
import ColorPicker from '@/components/ColorPicker'
import Input from '@/components/Input'
import Select from '@/components/Select'
import { Checkbox } from '@/components/ui/checkbox'
import { AlignRight, AlignBottom, AlignHorizontally, AlignLeft, AlignTop, AlignVertically, Link2, Add } from 'iconsax-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePersonalityStore } from '../store/Store'
import { ButtonSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
const ButtonSidebar: FC = () => {
const { t } = useTranslation()
const { addButtonToActiveItem } = usePersonalityStore()
const [buttonText, setButtonText] = useState<string>('')
const [buttonLink, setButtonLink] = useState<string>('')
const [buttonSize, setButtonSize] = useState<ButtonSize>(ButtonSize.MEDIUM)
const [isBorder, setIsBorder] = useState<boolean>(false)
const [borderSize, setBorderSize] = useState<number>(1)
const [textColor, setTextColor] = useState<string>('#fff')
const [backgroundColor, setBackgroundColor] = useState<string>('#000')
const [borderColor, setBorderColor] = useState<string>('#fff')
const [horizontalAlignment, setHorizontalAlignment] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER)
const [verticalAlignment, setVerticalAlignment] = useState<VerticalAlignment>(VerticalAlignment.MIDDLE)
const sizeOptions = [
{ label: 'کوچک', value: ButtonSize.SMALL },
{ label: 'متوسط', value: ButtonSize.MEDIUM },
{ label: 'بزرگ', value: ButtonSize.LARGE }
]
const handleAddButton = () => {
addButtonToActiveItem({
text: buttonText,
link: buttonLink,
size: buttonSize,
isBorder,
borderSize,
textColor,
backgroundColor,
borderColor,
alignment: horizontalAlignment,
verticalAlignment,
})
// Reset form after adding
setButtonText('')
setButtonLink('')
setButtonSize(ButtonSize.MEDIUM)
setIsBorder(false)
setBorderSize(1)
setTextColor('#fff')
setBackgroundColor('#000')
setBorderColor('#fff')
setHorizontalAlignment(HorizontalAlignment.CENTER)
setVerticalAlignment(VerticalAlignment.MIDDLE)
}
return (
<div>
<div className='text-lg'>{t('setting.button')}</div>
<div className='mt-8'>
<Input
label={t('setting.button_text')}
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
/>
</div>
<div className='mt-5'>
<Input
label={t('setting.button_link')}
value={buttonLink}
onChange={(e) => setButtonLink(e.target.value)}
endIcon={<Link2 size={18} color='#888' />}
/>
</div>
<div className='mt-5'>
<Select
label={t('setting.size')}
items={sizeOptions}
value={buttonSize}
onChange={(e) => setButtonSize(e.target.value as ButtonSize)}
placeholder={t('setting.select')}
/>
</div>
<div className='mt-5 flex gap-2 items-center text-sm'>
<Checkbox
checked={isBorder}
onCheckedChange={() => setIsBorder(!isBorder)}
/>
<div>{t('setting.border')}</div>
</div>
{
isBorder &&
<div className='mt-5'>
<Input
type='number'
label={t('setting.border')}
value={borderSize.toString()}
onChange={(e) => setBorderSize(Number(e.target.value))}
/>
</div>
}
<div className='mt-5'>
<ColorPicker
label={t('setting.text_color')}
defaultColor={textColor}
changeColor={setTextColor}
/>
</div>
<div className='mt-5'>
<ColorPicker
label={t('setting.background_color')}
defaultColor={backgroundColor}
changeColor={setBackgroundColor}
/>
</div>
<div className='mt-5'>
<ColorPicker
label={t('setting.border_color')}
defaultColor={borderColor}
changeColor={setBorderColor}
/>
</div>
<div className='mt-5 flex justify-between'>
<div>
<div className='text-sm'>
{t('setting.horizontal_position')}
</div>
<div className='mt-3 flex gap-4'>
<div
onClick={() => setHorizontalAlignment(HorizontalAlignment.RIGHT)}
>
<AlignRight size={20} color={horizontalAlignment === HorizontalAlignment.RIGHT ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setHorizontalAlignment(HorizontalAlignment.CENTER)}
>
<AlignVertically size={20} color={horizontalAlignment === HorizontalAlignment.CENTER ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setHorizontalAlignment(HorizontalAlignment.LEFT)}
>
<AlignLeft size={20} color={horizontalAlignment === HorizontalAlignment.LEFT ? '#0038FF' : '#000'} />
</div>
</div>
</div>
<div>
<div className='text-sm'>
{t('setting.vertical_position')}
</div>
<div className='mt-3 flex gap-4'>
<div
onClick={() => setVerticalAlignment(VerticalAlignment.TOP)}
>
<AlignTop size={20} color={verticalAlignment === VerticalAlignment.TOP ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setVerticalAlignment(VerticalAlignment.MIDDLE)}
>
<AlignHorizontally size={20} color={verticalAlignment === VerticalAlignment.MIDDLE ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setVerticalAlignment(VerticalAlignment.BOTTOM)}
>
<AlignBottom size={20} color={verticalAlignment === VerticalAlignment.BOTTOM ? '#0038FF' : '#000'} />
</div>
</div>
</div>
</div>
<div className='mt-7'>
<Button
onClick={handleAddButton}
className='bg-[#ECEEF5] text-black'
>
<div className='flex justify-center items-center gap-2'>
<Add size={24} color='black' />
<span>{t('setting.add_button')}</span>
</div>
</Button>
</div>
</div>
)
}
export default ButtonSidebar
@@ -0,0 +1,198 @@
import { FC } from 'react'
import { usePersonalityStore } from '../store/Store'
import { useTranslation } from 'react-i18next'
import { AddCircle } from 'iconsax-react'
import TextRenderer from './TextRenderer'
import ButtonRenderer from './ButtonRenderer'
import ImageRenderer from './ImageRenderer'
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
const ContentSection: FC = () => {
const { t } = useTranslation()
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
const handleSectionClick = (section: SectionName) => {
setActiveSection(section)
}
const handleItemClick = (index: number) => {
setActiveItemIndex(index)
}
// تابع‌های helper برای تعیین alignment دکمه‌ها
const getButtonHorizontalAlign = (button?: { alignment?: HorizontalAlignment }) => {
if (!button || !button.alignment) return 'center'
switch (button.alignment) {
case HorizontalAlignment.LEFT:
return 'left'
case HorizontalAlignment.RIGHT:
return 'right'
case HorizontalAlignment.CENTER:
default:
return 'center'
}
}
const getButtonVerticalAlign = (button?: { verticalAlignment?: VerticalAlignment }) => {
if (!button || !button.verticalAlignment) return 'middle'
switch (button.verticalAlignment) {
case VerticalAlignment.TOP:
return 'top'
case VerticalAlignment.BOTTOM:
return 'bottom'
case VerticalAlignment.MIDDLE:
default:
return 'middle'
}
}
return (
<tr onClick={() => handleSectionClick(SectionName.CONTENT)}>
<td style={{ paddingTop: '16px' }}>
{
data.content.columnsCount > 0 ?
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
<tbody>
<tr>
<td style={{
padding: '10px',
border: activeSection === SectionName.CONTENT ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
borderRadius: '24px'
}}>
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ tableLayout: 'fixed' }}>
<tbody>
<tr>
{
Array.from({ length: data.content.columnsCount }, (_, index) => {
const item = data.content.items[index];
const isActive = activeSection === SectionName.CONTENT && activeItemIndex === index;
return (
<>
<td
onClick={(e) => {
e.stopPropagation();
handleItemClick(index);
}}
key={index}
style={{
width: `${100 / data.content.columnsCount}%`,
height: '246px',
border: isActive ? '1px dashed #0038FF' : '1px dashed #EAECF4',
backgroundColor: item?.backgroundColor || '#ffffff',
backgroundImage: item?.backgroundImage ? `url(${item.backgroundImage})` : 'none',
backgroundSize: item?.style?.backgroundSize || 'cover',
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
backgroundPosition: item?.style?.backgroundPosition || 'center',
cursor: 'pointer',
padding: '0',
verticalAlign: 'top',
borderRadius: '8px',
position: 'relative'
}}
>
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{
borderCollapse: 'collapse',
height: '246px'
}}>
<tbody>
{/* اگر فقط دکمه داریم و متن نداریم */}
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
<tr>
<td
width="100%"
height="246"
align={getButtonHorizontalAlign(item.buttons[0])}
valign={getButtonVerticalAlign(item.buttons[0])}
style={{
padding: '8px'
}}
>
<ButtonRenderer buttons={item.buttons} />
</td>
</tr>
) : (
<>
{/* ردیف اصلی برای متن */}
<tr>
<td
width="100%"
height={item?.buttons && item.buttons.length > 0 ? "203" : "230"}
align={item?.texts?.[0]?.alignment || 'center'}
valign={item?.texts?.[0]?.verticalAlignment === VerticalAlignment.TOP ? 'top' :
item?.texts?.[0]?.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'middle'}
style={{
padding: '8px',
fontSize: '14px',
lineHeight: '1.4'
}}
>
<TextRenderer texts={item?.texts || []} />
<ImageRenderer images={item?.images || []} />
</td>
</tr>
{/* ردیف دکمه‌ها */}
{item?.buttons && item.buttons.length > 0 && (
<tr>
<td
width="100%"
height="27"
align={getButtonHorizontalAlign(item.buttons[0])}
valign={getButtonVerticalAlign(item.buttons[0])}
style={{
padding: '0 8px 8px 8px'
}}
>
<ButtonRenderer buttons={item.buttons} />
</td>
</tr>
)}
</>
)}
</tbody>
</table>
</td>
{index < data.content.columnsCount - 1 && (
<td style={{ width: '16px' }}></td>
)}
</>
)
})
}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
:
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
<tbody>
<tr>
<td style={{
height: '286px',
border: activeSection === SectionName.CONTENT ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
borderRadius: '24px',
textAlign: 'center',
verticalAlign: 'middle',
padding: '10px'
}}>
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
{t('setting.content_email')}
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<AddCircle size={24} color='#888' />
</div>
</td>
</tr>
</tbody>
</table>
}
</td>
</tr>
)
}
export default ContentSection
@@ -0,0 +1,198 @@
import { FC } from 'react'
import { usePersonalityStore } from '../store/Store'
import { useTranslation } from 'react-i18next'
import { AddCircle } from 'iconsax-react'
import TextRenderer from './TextRenderer'
import ButtonRenderer from './ButtonRenderer'
import ImageRenderer from './ImageRenderer'
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
const FooterSection: FC = () => {
const { t } = useTranslation()
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
const handleSectionClick = (section: SectionName) => {
setActiveSection(section)
}
const handleItemClick = (index: number) => {
setActiveItemIndex(index)
}
// تابع‌های helper برای تعیین alignment دکمه‌ها
const getButtonHorizontalAlign = (button?: { alignment?: HorizontalAlignment }) => {
if (!button || !button.alignment) return 'center'
switch (button.alignment) {
case HorizontalAlignment.LEFT:
return 'left'
case HorizontalAlignment.RIGHT:
return 'right'
case HorizontalAlignment.CENTER:
default:
return 'center'
}
}
const getButtonVerticalAlign = (button?: { verticalAlignment?: VerticalAlignment }) => {
if (!button || !button.verticalAlignment) return 'middle'
switch (button.verticalAlignment) {
case VerticalAlignment.TOP:
return 'top'
case VerticalAlignment.BOTTOM:
return 'bottom'
case VerticalAlignment.MIDDLE:
default:
return 'middle'
}
}
return (
<tr onClick={() => handleSectionClick(SectionName.FOOTER)}>
<td style={{ paddingTop: '16px' }}>
{
data.footer.columnsCount > 0 ?
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
<tbody>
<tr>
<td style={{
padding: '10px',
border: activeSection === SectionName.FOOTER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
borderRadius: '24px'
}}>
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ tableLayout: 'fixed' }}>
<tbody>
<tr>
{
Array.from({ length: data.footer.columnsCount }, (_, index) => {
const item = data.footer.items[index];
const isActive = activeSection === SectionName.FOOTER && activeItemIndex === index;
return (
<>
<td
onClick={(e) => {
e.stopPropagation();
handleItemClick(index);
}}
key={index}
style={{
width: `${100 / data.footer.columnsCount}%`,
height: '123px',
border: isActive ? '1px dashed #0038FF' : '1px dashed #EAECF4',
backgroundColor: item?.backgroundColor || '#ffffff',
backgroundImage: item?.backgroundImage ? `url(${item.backgroundImage})` : 'none',
backgroundSize: item?.style?.backgroundSize || 'cover',
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
backgroundPosition: item?.style?.backgroundPosition || 'center',
cursor: 'pointer',
padding: '0',
verticalAlign: 'top',
borderRadius: '8px',
position: 'relative'
}}
>
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{
borderCollapse: 'collapse',
height: '123px'
}}>
<tbody>
{/* اگر فقط دکمه داریم و متن نداریم */}
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
<tr>
<td
width="100%"
height="123"
align={getButtonHorizontalAlign(item.buttons[0])}
valign={getButtonVerticalAlign(item.buttons[0])}
style={{
padding: '8px'
}}
>
<ButtonRenderer buttons={item.buttons} />
</td>
</tr>
) : (
<>
{/* ردیف اصلی برای متن */}
<tr>
<td
width="100%"
height={item?.buttons && item.buttons.length > 0 ? "40" : "67"}
align={item?.texts?.[0]?.alignment || 'center'}
valign={item?.texts?.[0]?.verticalAlignment === VerticalAlignment.TOP ? 'top' :
item?.texts?.[0]?.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'middle'}
style={{
padding: '8px',
fontSize: '14px',
lineHeight: '1.4'
}}
>
<TextRenderer texts={item?.texts || []} />
<ImageRenderer images={item?.images || []} />
</td>
</tr>
{/* ردیف دکمه‌ها */}
{item?.buttons && item.buttons.length > 0 && (
<tr>
<td
width="100%"
height="27"
align={getButtonHorizontalAlign(item.buttons[0])}
valign={getButtonVerticalAlign(item.buttons[0])}
style={{
padding: '0 8px 8px 8px'
}}
>
<ButtonRenderer buttons={item.buttons} />
</td>
</tr>
)}
</>
)}
</tbody>
</table>
</td>
{index < data.footer.columnsCount - 1 && (
<td style={{ width: '16px' }}></td>
)}
</>
)
})
}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
:
<table width="100%" cellPadding="0" cellSpacing="0" border={0}>
<tbody>
<tr>
<td style={{
height: '123px',
border: activeSection === SectionName.FOOTER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
borderRadius: '24px',
textAlign: 'center',
verticalAlign: 'middle',
padding: '10px'
}}>
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
{t('setting.footer_email')}
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<AddCircle size={24} color='#888' />
</div>
</td>
</tr>
</tbody>
</table>
}
</td>
</tr>
)
}
export default FooterSection
@@ -0,0 +1,184 @@
import { FC } from 'react'
import { usePersonalityStore } from '../store/Store'
import { useTranslation } from 'react-i18next'
import { AddCircle } from 'iconsax-react'
import TextRenderer from './TextRenderer'
import ButtonRenderer from './ButtonRenderer'
import ImageRenderer from './ImageRenderer'
import { SectionName, VerticalAlignment, HorizontalAlignment } from '../types/Types'
const HeaderSection: FC = () => {
const { t } = useTranslation()
const { activeSection, activeItemIndex, data, setActiveSection, setActiveItemIndex } = usePersonalityStore()
const handleSectionClick = (section: SectionName) => {
setActiveSection(section)
}
const handleItemClick = (index: number) => {
setActiveItemIndex(index)
}
// تابع‌های helper برای تعیین alignment دکمه‌ها
const getButtonHorizontalAlign = (button?: { alignment?: HorizontalAlignment }) => {
if (!button || !button.alignment) return 'center'
switch (button.alignment) {
case HorizontalAlignment.LEFT:
return 'left'
case HorizontalAlignment.RIGHT:
return 'right'
case HorizontalAlignment.CENTER:
default:
return 'center'
}
}
const getButtonVerticalAlign = (button?: { verticalAlignment?: VerticalAlignment }) => {
if (!button || !button.verticalAlignment) return 'middle'
switch (button.verticalAlignment) {
case VerticalAlignment.TOP:
return 'top'
case VerticalAlignment.BOTTOM:
return 'bottom'
case VerticalAlignment.MIDDLE:
default:
return 'middle'
}
}
return (
<tr onClick={() => handleSectionClick(SectionName.HEADER)}>
{
data.header.columnsCount > 0 ?
<td style={{
padding: '10px',
border: activeSection === SectionName.HEADER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
borderRadius: '24px'
}}>
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ tableLayout: 'fixed' }}>
<tbody>
<tr>
{
Array.from({ length: data.header.columnsCount }, (_, index) => {
const item = data.header.items[index];
const isActive = activeSection === SectionName.HEADER && activeItemIndex === index;
return (
<>
<td
onClick={(e) => {
e.stopPropagation();
handleItemClick(index);
}}
key={index}
style={{
width: `${100 / data.header.columnsCount}%`,
height: '123px',
border: isActive ? '1px dashed #0038FF' : '1px dashed #EAECF4',
backgroundColor: item?.backgroundColor || '#ffffff',
backgroundImage: item?.backgroundImage ? `url(${item.backgroundImage})` : 'none',
backgroundSize: item?.style?.backgroundSize || 'cover',
backgroundRepeat: item?.style?.backgroundRepeat || 'no-repeat',
backgroundPosition: item?.style?.backgroundPosition || 'center',
cursor: 'pointer',
padding: '0',
verticalAlign: 'top',
borderRadius: '8px',
position: 'relative'
}}
>
<table width="100%" cellPadding="0" cellSpacing="0" border={0} style={{
borderCollapse: 'collapse',
height: '123px'
}}>
<tbody>
{/* اگر فقط دکمه داریم و متن نداریم */}
{(!item?.texts || item.texts.length === 0) && item?.buttons && item.buttons.length > 0 ? (
<tr>
<td
width="100%"
height="123"
align={getButtonHorizontalAlign(item.buttons[0])}
valign={getButtonVerticalAlign(item.buttons[0])}
style={{
padding: '8px'
}}
>
<ButtonRenderer buttons={item.buttons} />
</td>
</tr>
) : (
<>
{/* ردیف اصلی برای متن */}
<tr>
<td
width="100%"
height={item?.buttons && item.buttons.length > 0 ? "60" : "87"}
align={item?.texts?.[0]?.alignment || 'center'}
valign={item?.texts?.[0]?.verticalAlignment === VerticalAlignment.TOP ? 'top' :
item?.texts?.[0]?.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'middle'}
style={{
padding: '8px',
fontSize: '14px',
lineHeight: '1.4'
}}
>
<TextRenderer texts={item?.texts || []} />
<ImageRenderer images={item?.images || []} />
</td>
</tr>
{/* ردیف دکمه‌ها */}
{item?.buttons && item.buttons.length > 0 && (
<tr>
<td
width="100%"
height="27"
align={getButtonHorizontalAlign(item.buttons[0])}
valign={getButtonVerticalAlign(item.buttons[0])}
style={{
padding: '0 8px 8px 8px'
}}
>
<ButtonRenderer buttons={item.buttons} />
</td>
</tr>
)}
</>
)}
</tbody>
</table>
</td>
{index < data.header.columnsCount - 1 && (
<td style={{ width: '16px' }}></td>
)}
</>
)
})
}
</tr>
</tbody>
</table>
</td>
:
<td style={{
height: '123px',
border: activeSection === SectionName.HEADER ? '1px dashed #0038FF' : '1px dashed #E5E7EB',
borderRadius: '24px',
textAlign: 'center',
verticalAlign: 'middle',
padding: '10px'
}}>
<div style={{ color: '#888', fontSize: '14px', marginBottom: '8px' }}>
{t('setting.header_email')}
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<AddCircle size={24} color='#888' />
</div>
</td>
}
</tr>
)
}
export default HeaderSection
@@ -0,0 +1,149 @@
import { FC } from 'react'
import { ImageType, ImageSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
interface ImageRendererProps {
images: ImageType[]
}
const ImageRenderer: FC<ImageRendererProps> = ({ images }) => {
if (!images || images.length === 0) return null
// تابع‌های helper برای تبدیل enum به string
const getHorizontalAlign = (alignment?: HorizontalAlignment) => {
switch (alignment) {
case HorizontalAlignment.LEFT:
return 'left'
case HorizontalAlignment.RIGHT:
return 'right'
case HorizontalAlignment.CENTER:
default:
return 'center'
}
}
const getVerticalAlign = (verticalAlignment?: VerticalAlignment) => {
switch (verticalAlignment) {
case VerticalAlignment.TOP:
return 'top'
case VerticalAlignment.BOTTOM:
return 'bottom'
case VerticalAlignment.MIDDLE:
default:
return 'middle'
}
}
const getImageStyle = (image: ImageType) => {
return {
width: image.width || '100%',
height: image.height || 'auto',
border: '0'
}
}
const shouldUseBackgroundImage = (imageSize: ImageSize) => {
return imageSize === ImageSize.REPEAT ||
imageSize === ImageSize.REPEAT_X ||
imageSize === ImageSize.REPEAT_Y
}
const getBackgroundStyle = (image: ImageType) => {
let backgroundRepeat = 'no-repeat'
switch (image.size) {
case ImageSize.REPEAT:
backgroundRepeat = 'repeat'
break
case ImageSize.REPEAT_X:
backgroundRepeat = 'repeat-x'
break
case ImageSize.REPEAT_Y:
backgroundRepeat = 'repeat-y'
break
default:
backgroundRepeat = 'no-repeat'
break
}
const horizontal = image.alignment === HorizontalAlignment.LEFT ? 'left' :
image.alignment === HorizontalAlignment.RIGHT ? 'right' : 'center'
const vertical = image.verticalAlignment === VerticalAlignment.TOP ? 'top' :
image.verticalAlignment === VerticalAlignment.BOTTOM ? 'bottom' : 'center'
return {
backgroundImage: `url(${image.src})`,
backgroundPosition: `${horizontal} ${vertical}`,
backgroundRepeat,
width: image.width || '100%',
height: image.height || '100px'
}
}
const renderImage = (image: ImageType) => {
if (shouldUseBackgroundImage(image.size)) {
// برای pattern های repeat از background استفاده می‌کنیم
return (
<div style={getBackgroundStyle(image)}>
&nbsp;
</div>
)
}
// برای تصاویر عادی
if (image.size === ImageSize.CONTAIN) {
// برای contain از table wrapper استفاده می‌کنیم تا نسبت حفظ شود
return (
<table width={image.width || '100%'} cellPadding="0" cellSpacing="0" border={0}>
<tbody>
<tr>
<td align="center" valign="middle" style={{ textAlign: 'center' }}>
<img
src={image.src}
alt={image.alt || 'تصویر آپلود شده'}
width={image.width || '100%'}
height={image.height || 'auto'}
style={{ border: '0' }}
/>
</td>
</tr>
</tbody>
</table>
)
}
// برای cover و auto
return (
<img
src={image.src}
alt={image.alt || 'تصویر آپلود شده'}
width={image.width || '100%'}
height={image.height || 'auto'}
style={getImageStyle(image)}
/>
)
}
return (
<>
{images.map((image, imageIndex) => (
<table key={image.id} width="100%" cellPadding="0" cellSpacing="0" border={0}>
<tbody>
<tr>
<td
align={getHorizontalAlign(image.alignment)}
valign={getVerticalAlign(image.verticalAlignment)}
style={{
paddingBottom: imageIndex < images.length - 1 ? '4px' : '0'
}}
>
{renderImage(image)}
</td>
</tr>
</tbody>
</table>
))}
</>
)
}
export default ImageRenderer
@@ -0,0 +1,194 @@
import Button from '@/components/Button'
import Select from '@/components/Select'
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically } from 'iconsax-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePersonalityStore } from '../store/Store'
import { ImageSize, HorizontalAlignment, VerticalAlignment } from '../types/Types'
const ImageSideBar: FC = () => {
const { t } = useTranslation()
const { addImageToActiveItem, activeSection, activeItemIndex, data } = usePersonalityStore()
const [imageSrc, setImageSrc] = useState<string>('')
const [imageSize, setImageSize] = useState<ImageSize>(ImageSize.COVER)
const [horizontalAlignment, setHorizontalAlignment] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER)
const [verticalAlignment, setVerticalAlignment] = useState<VerticalAlignment>(VerticalAlignment.MIDDLE)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isReset, setIsReset] = useState<boolean>(false)
const sizeOptions = [
{ label: 'کاور - تمام فضا را پوشش دهد (Cover)', value: ImageSize.COVER },
{ label: 'در مقیاس - در فضا جا شود (Contain)', value: ImageSize.CONTAIN },
{ label: 'تکرار - عکس تکرار شود (Repeat)', value: ImageSize.REPEAT },
{ label: 'تکرار افقی (Repeat-X)', value: ImageSize.REPEAT_X },
{ label: 'تکرار عمودی (Repeat-Y)', value: ImageSize.REPEAT_Y },
{ label: 'بدون تکرار (No-Repeat)', value: ImageSize.NO_REPEAT },
{ label: 'اندازه اصلی (Auto)', value: ImageSize.AUTO }
]
const handleImageUpload = (files: File[]) => {
if (files.length === 0) return
const file = files[0] // فقط اولین فایل را در نظر می‌گیریم
// در اینجا باید فایل آپلود شود و URL برگردانده شود
// فعلاً از FileReader استفاده می‌کنم برای تست
const reader = new FileReader()
reader.onload = (e) => {
const result = e.target?.result as string
setImageSrc(result)
}
reader.readAsDataURL(file)
}
const handleAddImage = () => {
if (!imageSrc) return
console.log('Store state before adding image:', {
activeSection,
activeItemIndex,
hasActiveItem: activeSection && data[activeSection as keyof typeof data]?.items[activeItemIndex],
})
// Validation
if (!activeSection) {
console.error('No active section selected')
return
}
const sectionData = data[activeSection as keyof typeof data]
if (!sectionData?.items || activeItemIndex >= sectionData.items.length) {
console.error('No active item found or invalid index', {
activeItemIndex,
itemsLength: sectionData?.items?.length
})
return
}
console.log('Adding image with data:', {
src: imageSrc,
alt: 'عکس آپلود شده',
size: imageSize,
alignment: horizontalAlignment,
verticalAlignment,
})
setIsLoading(true)
try {
addImageToActiveItem({
src: imageSrc,
alt: 'عکس آپلود شده',
size: imageSize,
alignment: horizontalAlignment,
verticalAlignment,
})
console.log('Image added successfully')
// Reset form after adding
setImageSrc('')
setImageSize(ImageSize.COVER)
setHorizontalAlignment(HorizontalAlignment.CENTER)
setVerticalAlignment(VerticalAlignment.MIDDLE)
setIsReset(true)
setTimeout(() => {
setIsReset(false)
}, 1000)
} catch (error) {
console.error('Error adding image:', error)
} finally {
setIsLoading(false)
}
}
return (
<div>
<div className='text-lg'>{t('setting.image')}</div>
<div className='mt-8'>
<UploadBoxDraggble
label={t('setting.upload_image')}
onChange={handleImageUpload}
isReset={isReset}
/>
</div>
<div className='mt-5'>
<Select
items={sizeOptions}
label={t('setting.size')}
value={imageSize}
onChange={(e) => setImageSize(e.target.value as ImageSize)}
placeholder={t('setting.size')}
/>
</div>
<div className='mt-5 flex justify-between'>
<div>
<div className='text-sm'>
{t('setting.horizontal_position')}
</div>
<div className='mt-3 flex gap-4'>
<div
onClick={() => setHorizontalAlignment(HorizontalAlignment.RIGHT)}
>
<AlignRight size={20} color={horizontalAlignment === HorizontalAlignment.RIGHT ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setHorizontalAlignment(HorizontalAlignment.CENTER)}
>
<AlignVertically size={20} color={horizontalAlignment === HorizontalAlignment.CENTER ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setHorizontalAlignment(HorizontalAlignment.LEFT)}
>
<AlignLeft size={20} color={horizontalAlignment === HorizontalAlignment.LEFT ? '#0038FF' : '#000'} />
</div>
</div>
</div>
<div>
<div className='text-sm'>
{t('setting.vertical_position')}
</div>
<div className='mt-3 flex gap-4'>
<div
onClick={() => setVerticalAlignment(VerticalAlignment.BOTTOM)}
>
<AlignBottom size={20} color={verticalAlignment === VerticalAlignment.BOTTOM ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setVerticalAlignment(VerticalAlignment.MIDDLE)}
>
<AlignHorizontally size={20} color={verticalAlignment === VerticalAlignment.MIDDLE ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setVerticalAlignment(VerticalAlignment.TOP)}
>
<AlignTop size={20} color={verticalAlignment === VerticalAlignment.TOP ? '#0038FF' : '#000'} />
</div>
</div>
</div>
</div>
<div className='mt-7'>
<Button
onClick={handleAddImage}
className='bg-[#ECEEF5] text-black'
disabled={!imageSrc}
loading={isLoading}
>
<div className='flex justify-center items-center gap-2'>
<Add size={24} color='black' />
<span>{t('setting.add_image')}</span>
</div>
</Button>
</div>
</div>
)
}
export default ImageSideBar
@@ -0,0 +1,187 @@
import { FC, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import UploadBoxDraggble from '@/components/UploadBoxDraggble';
import ColorPicker from '@/components/ColorPicker';
import { usePersonalityStore } from '../store/Store';
import { SectionItemType, BackgroundSize, SectionName } from '../types/Types';
import Select from '@/components/Select';
const SettingSideBar: FC = () => {
const { t } = useTranslation()
const { activeSection, setColumnsCount, updateActiveItem, data, activeItemIndex } = usePersonalityStore()
const [color, setColor] = useState("#aabbcc");
const [backgroundSize, setBackgroundSize] = useState<BackgroundSize>(BackgroundSize.COVER)
// تشخیص background size بر اساس style موجود
const getCurrentBackgroundSizeType = (style?: SectionItemType['style']) => {
if (!style) return BackgroundSize.COVER
const bgSize = style.backgroundSize
const bgRepeat = style.backgroundRepeat
if (bgSize === 'cover') return BackgroundSize.COVER
if (bgSize === 'contain') return BackgroundSize.CONTAIN
if (bgSize === 'auto' || !bgSize) {
if (bgRepeat === 'round') return BackgroundSize.ROUND
}
return BackgroundSize.COVER
}
// به‌روزرسانی state وقتی activeSection یا activeItem تغییر می‌کند
useEffect(() => {
if (activeSection && data[activeSection as keyof typeof data]) {
const currentItem = data[activeSection as keyof typeof data]?.items[activeItemIndex]
if (currentItem?.style) {
const currentType = getCurrentBackgroundSizeType(currentItem.style)
setBackgroundSize(currentType)
}
}
}, [activeSection, activeItemIndex, data])
const handleColumnClick = (columns: number) => {
if (activeSection) {
setColumnsCount(activeSection as SectionName, columns);
}
}
const handleColorChange = (color: string) => {
setColor(color)
updateActiveItem({ backgroundColor: color })
}
const handleUploadBackground = (file: File[]) => {
if (activeSection && file.length > 0) {
const imageUrl = URL.createObjectURL(file[0])
console.log('Setting background image:', imageUrl)
const currentItem = data[activeSection as keyof typeof data]?.items[activeItemIndex]
updateActiveItem({
backgroundImage: imageUrl,
style: {
...currentItem?.style,
backgroundSize: getBackgroundSize(backgroundSize),
backgroundRepeat: getBackgroundRepeat(backgroundSize),
backgroundPosition: 'center center'
}
})
}
}
const getBackgroundRepeat = (size: BackgroundSize) => {
switch (size) {
case BackgroundSize.ROUND:
return 'round'
case BackgroundSize.COVER:
case BackgroundSize.CONTAIN:
default:
return 'no-repeat'
}
}
const getBackgroundSize = (size: BackgroundSize) => {
switch (size) {
case BackgroundSize.COVER:
return 'cover'
case BackgroundSize.CONTAIN:
return 'contain'
case BackgroundSize.ROUND:
return 'auto'
default:
return 'cover'
}
}
const sizeOptions = [
{ label: 'کاور - تمام فضا را پوشش دهد (Cover)', value: BackgroundSize.COVER },
{ label: 'در مقیاس - در فضا جا شود (Contain)', value: BackgroundSize.CONTAIN },
{ label: 'گرد - عکس به صورت گرد تکرار شود (Round)', value: BackgroundSize.ROUND }
]
if (!activeSection) {
return (
<div className='text-center text-gray-500 mt-8'>
{t('setting.select_section')}
</div>
)
}
return (
<div>
<div className='text-lg'>{t('setting.setting')}</div>
<div className='flex h-[51px] gap-5 mt-6'>
<div
className='w-[102px] h-full bg-[#EAECF4] cursor-pointer hover:bg-[#d1d5db] transition-colors'
onClick={() => handleColumnClick(1)}
></div>
<div
className='w-[102px] h-full flex gap-1 cursor-pointer group'
onClick={() => handleColumnClick(2)}
>
<div className='flex-1 bg-[#EAECF4] group-hover:bg-[#d1d5db] transition-colors'></div>
<div className='flex-1 bg-[#EAECF4] group-hover:bg-[#d1d5db] transition-colors'></div>
</div>
</div>
<div className='flex h-[51px] gap-5 mt-5'>
<div
className='w-[102px] h-full flex gap-1 cursor-pointer group'
onClick={() => handleColumnClick(3)}
>
<div className='flex-1 bg-[#EAECF4] group-hover:bg-[#d1d5db] transition-colors'></div>
<div className='flex-1 bg-[#EAECF4] group-hover:bg-[#d1d5db] transition-colors'></div>
<div className='flex-1 bg-[#EAECF4] group-hover:bg-[#d1d5db] transition-colors'></div>
</div>
<div
className='w-[102px] h-full flex gap-1 cursor-pointer group'
onClick={() => handleColumnClick(4)}
>
<div className='flex-1 bg-[#EAECF4] group-hover:bg-[#d1d5db] transition-colors'></div>
<div className='flex-1 bg-[#EAECF4] group-hover:bg-[#d1d5db] transition-colors'></div>
<div className='flex-1 bg-[#EAECF4] group-hover:bg-[#d1d5db] transition-colors'></div>
<div className='flex-1 bg-[#EAECF4] group-hover:bg-[#d1d5db] transition-colors'></div>
</div>
</div>
<div className='mt-5'>
<ColorPicker
label={t('setting.background')}
defaultColor={color}
changeColor={handleColorChange}
/>
</div>
<div className='mt-5'>
<UploadBoxDraggble
label={t('setting.upload_background')}
onChange={handleUploadBackground}
/>
</div>
<div className='mt-5'>
<Select
items={sizeOptions}
label={t('setting.size')}
value={backgroundSize}
onChange={(e) => {
const newSize = e.target.value as BackgroundSize
setBackgroundSize(newSize)
const currentItem = activeSection ? data[activeSection as keyof typeof data]?.items[activeItemIndex] : null
updateActiveItem({
style: {
...currentItem?.style,
backgroundSize: getBackgroundSize(newSize),
backgroundRepeat: getBackgroundRepeat(newSize),
backgroundPosition: 'center center'
}
})
}}
placeholder={t('setting.size')}
/>
</div>
</div>
)
}
export default SettingSideBar
@@ -0,0 +1,33 @@
import { FC } from 'react'
import { TextType } from '../types/Types'
interface TextRendererProps {
texts: TextType[]
}
const TextRenderer: FC<TextRendererProps> = ({ texts }) => {
return (
<>
{texts?.map((textItem, textIndex) => (
<table key={textItem.id} width="100%" cellPadding="0" cellSpacing="0" border={0} style={{ borderCollapse: 'collapse' }}>
<tbody>
<tr>
<td
align={textItem.alignment || 'center'}
style={{
color: textItem.color || '#000000',
fontSize: `${textItem.fontSize || 14}px`,
lineHeight: '1.4',
paddingBottom: textIndex < (texts?.length || 0) - 1 ? '4px' : '0'
}}
dangerouslySetInnerHTML={{ __html: textItem.text }}
/>
</tr>
</tbody>
</table>
))}
</>
)
}
export default TextRenderer
@@ -0,0 +1,136 @@
import Button from '@/components/Button';
import { Add, AlignBottom, AlignHorizontally, AlignLeft, AlignRight, AlignTop, AlignVertically } from 'iconsax-react';
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReactQuill from 'react-quill-new';
import { usePersonalityStore } from '../store/Store';
import ColorPicker from '@/components/ColorPicker';
import { HorizontalAlignment, VerticalAlignment } from '../types/Types';
const TextSidebar: FC = () => {
const { t } = useTranslation()
const [value, setValue] = useState('');
const [color, setColor] = useState('#000');
const [horizontalPosition, setHorizontalPosition] = useState<HorizontalAlignment>(HorizontalAlignment.CENTER);
const [verticalPosition, setVerticalPosition] = useState<VerticalAlignment>(VerticalAlignment.MIDDLE);
const { addTextToActiveItem } = usePersonalityStore();
const handleChange = (value: string) => {
setValue(value);
}
const handleAddText = () => {
if (value.trim()) {
addTextToActiveItem({
text: value,
color: color,
verticalAlignment: verticalPosition,
alignment: horizontalPosition,
});
// خالی کردن فیلدها بعد از ذخیره
setValue('');
setColor('#000');
setHorizontalPosition(HorizontalAlignment.CENTER);
setVerticalPosition(VerticalAlignment.MIDDLE);
}
}
return (
<div>
<div className='text-lg'>{t('setting.text')}</div>
<div className='mt-8'>
<ReactQuill
modules={{
toolbar: [
[{ header: '1' }, { header: '2' }, { font: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['bold', 'italic', 'underline'],
['link', 'image'],
[{ align: [] }],
['clean']
]
}}
theme="snow"
value={value}
onChange={handleChange}
style={{ minHeight: '120px' }}
className='text-sm'
/>
</div>
<div className='mt-5'>
<ColorPicker
changeColor={setColor}
label={t('setting.text_color')}
defaultColor={color}
/>
</div>
<div className='mt-5 flex justify-between'>
<div>
<div className='text-sm'>
{t('setting.horizontal_position')}
</div>
<div className='mt-3 flex gap-4'>
<div
onClick={() => setHorizontalPosition(HorizontalAlignment.RIGHT)}
>
<AlignRight size={20} color={horizontalPosition === HorizontalAlignment.RIGHT ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setHorizontalPosition(HorizontalAlignment.CENTER)}
>
<AlignVertically size={20} color={horizontalPosition === HorizontalAlignment.CENTER ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setHorizontalPosition(HorizontalAlignment.LEFT)}
>
<AlignLeft size={20} color={horizontalPosition === HorizontalAlignment.LEFT ? '#0038FF' : '#000'} />
</div>
</div>
</div>
<div>
<div className='text-sm'>
{t('setting.vertical_position')}
</div>
<div className='mt-3 flex gap-4'>
<div
onClick={() => setVerticalPosition(VerticalAlignment.TOP)}
>
<AlignTop size={20} color={verticalPosition === VerticalAlignment.TOP ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setVerticalPosition(VerticalAlignment.MIDDLE)}
>
<AlignHorizontally size={20} color={verticalPosition === VerticalAlignment.MIDDLE ? '#0038FF' : '#000'} />
</div>
<div
onClick={() => setVerticalPosition(VerticalAlignment.BOTTOM)}
>
<AlignBottom size={20} color={verticalPosition === VerticalAlignment.BOTTOM ? '#0038FF' : '#000'} />
</div>
</div>
</div>
</div>
<div className='mt-7'>
<Button
onClick={handleAddText}
className='bg-[#ECEEF5] text-black'
>
<div className='flex justify-center items-center gap-2'>
<Add size={24} color='black' />
<span>{t('setting.add_text')}</span>
</div>
</Button>
</div>
</div>
)
}
export default TextSidebar
@@ -0,0 +1,376 @@
import { create } from "zustand";
import { v4 as uuidv4 } from "uuid";
import {
PersonalityStore,
PersonalityDataType,
SectionItemType,
TextType,
ButtonType,
ImageType,
SectionName,
HorizontalAlignment,
VerticalAlignment,
} from "../types/Types";
export const usePersonalityStore = create<PersonalityStore>((set) => ({
data: {
header: { columnsCount: 0, items: [] },
content: { columnsCount: 0, items: [] },
footer: { columnsCount: 0, items: [] },
},
activeSection: "",
activeItemIndex: 0,
setActiveSection: (section: SectionName) =>
set({ activeSection: section, activeItemIndex: 0 }),
setActiveItemIndex: (index: number) => set({ activeItemIndex: index }),
setColumnsCount: (
sectionKey: keyof PersonalityDataType,
columnsCount: number
) =>
set((state) => {
const currentItems = state.data[sectionKey].items;
const newItems = Array.from(
{ length: columnsCount },
(_, index) =>
currentItems[index] || {
id: uuidv4(),
backgroundColor: "#ffffff",
texts: [],
buttons: [],
images: [],
alignment: HorizontalAlignment.CENTER,
verticalAlignment: VerticalAlignment.MIDDLE,
}
);
return {
data: {
...state.data,
[sectionKey]: {
columnsCount,
items: newItems,
},
},
activeItemIndex: 0,
};
}),
setItems: (sectionKey: keyof PersonalityDataType, items: SectionItemType[]) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items,
},
},
})),
addItem: (
sectionKey: keyof PersonalityDataType,
item: Omit<SectionItemType, "id">
) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: [...state.data[sectionKey].items, { ...item, id: uuidv4() }],
},
},
})),
updateActiveItem: (updates: Partial<SectionItemType>) =>
set((state) => {
if (!state.activeSection) return state;
const sectionKey = state.activeSection as keyof PersonalityDataType;
const items = state.data[sectionKey].items;
const activeIndex = state.activeItemIndex;
if (activeIndex >= items.length) return state;
console.log("Updating active item:", {
sectionKey,
activeIndex,
updates,
});
const updatedItems = items.map((item, index) =>
index === activeIndex ? { ...item, ...updates } : item
);
return {
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: updatedItems,
},
},
};
}),
updateItem: (
sectionKey: keyof PersonalityDataType,
id: string,
updates: Partial<SectionItemType>
) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: state.data[sectionKey].items.map((item) =>
item.id === id ? { ...item, ...updates } : item
),
},
},
})),
removeItem: (sectionKey: keyof PersonalityDataType, id: string) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: state.data[sectionKey].items.filter((item) => item.id !== id),
},
},
})),
addText: (
sectionKey: keyof PersonalityDataType,
itemId: string,
text: Omit<TextType, "id">
) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: state.data[sectionKey].items.map((item) =>
item.id === itemId
? {
...item,
texts: [...item.texts, { ...text, id: uuidv4() }],
}
: item
),
},
},
})),
addTextToActiveItem: (text: Omit<TextType, "id">) =>
set((state) => {
if (!state.activeSection) return state;
const sectionKey = state.activeSection as keyof PersonalityDataType;
const items = state.data[sectionKey].items;
const activeIndex = state.activeItemIndex;
if (activeIndex >= items.length) return state;
const updatedItems = items.map((item, index) =>
index === activeIndex
? {
...item,
texts: [...item.texts, { ...text, id: uuidv4() }],
}
: item
);
return {
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: updatedItems,
},
},
};
}),
addButton: (
sectionKey: keyof PersonalityDataType,
itemId: string,
button: Omit<ButtonType, "id">
) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: state.data[sectionKey].items.map((item) =>
item.id === itemId
? {
...item,
buttons: [...item.buttons, { ...button, id: uuidv4() }],
}
: item
),
},
},
})),
addButtonToActiveItem: (button: Omit<ButtonType, "id">) =>
set((state) => {
if (!state.activeSection) return state;
const sectionKey = state.activeSection as keyof PersonalityDataType;
const items = state.data[sectionKey].items;
const activeIndex = state.activeItemIndex;
if (activeIndex >= items.length) return state;
const updatedItems = items.map((item, index) =>
index === activeIndex
? {
...item,
buttons: [...item.buttons, { ...button, id: uuidv4() }],
}
: item
);
return {
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: updatedItems,
},
},
};
}),
updateText: (
sectionKey: keyof PersonalityDataType,
itemId: string,
textId: string,
updates: Partial<TextType>
) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: state.data[sectionKey].items.map((item) =>
item.id === itemId
? {
...item,
texts: item.texts.map((text) =>
text.id === textId ? { ...text, ...updates } : text
),
}
: item
),
},
},
})),
updateButton: (
sectionKey: keyof PersonalityDataType,
itemId: string,
buttonId: string,
updates: Partial<ButtonType>
) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: state.data[sectionKey].items.map((item) =>
item.id === itemId
? {
...item,
buttons: item.buttons.map((button) =>
button.id === buttonId ? { ...button, ...updates } : button
),
}
: item
),
},
},
})),
addImage: (
sectionKey: keyof PersonalityDataType,
itemId: string,
image: Omit<ImageType, "id">
) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: state.data[sectionKey].items.map((item) =>
item.id === itemId
? {
...item,
images: [...item.images, { ...image, id: uuidv4() }],
}
: item
),
},
},
})),
addImageToActiveItem: (image: Omit<ImageType, "id">) =>
set((state) => {
if (!state.activeSection) return state;
const sectionKey = state.activeSection as keyof PersonalityDataType;
const items = state.data[sectionKey].items;
const activeIndex = state.activeItemIndex;
if (activeIndex >= items.length) return state;
const updatedItems = items.map((item, index) =>
index === activeIndex
? {
...item,
images: [...item.images, { ...image, id: uuidv4() }],
}
: item
);
return {
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: updatedItems,
},
},
};
}),
updateImage: (
sectionKey: keyof PersonalityDataType,
itemId: string,
imageId: string,
updates: Partial<ImageType>
) =>
set((state) => ({
data: {
...state.data,
[sectionKey]: {
...state.data[sectionKey],
items: state.data[sectionKey].items.map((item) =>
item.id === itemId
? {
...item,
images: item.images.map((image) =>
image.id === imageId ? { ...image, ...updates } : image
),
}
: item
),
},
},
})),
}));
@@ -0,0 +1,170 @@
// Enums for common types
export enum HorizontalAlignment {
LEFT = "left",
CENTER = "center",
RIGHT = "right",
}
export enum VerticalAlignment {
TOP = "top",
MIDDLE = "middle",
BOTTOM = "bottom",
}
export enum ButtonSize {
SMALL = "small",
MEDIUM = "medium",
LARGE = "large",
}
export enum ImageSize {
COVER = "cover",
CONTAIN = "contain",
AUTO = "auto",
REPEAT = "repeat",
REPEAT_X = "repeat-x",
REPEAT_Y = "repeat-y",
NO_REPEAT = "no-repeat",
}
export enum BackgroundSize {
COVER = "cover",
CONTAIN = "contain",
ROUND = "round",
}
export enum SectionName {
HEADER = "header",
CONTENT = "content",
FOOTER = "footer",
}
export type PersonalityDataType = {
header: SectionType;
content: SectionType;
footer: SectionType;
};
export type SectionType = {
columnsCount: number;
items: SectionItemType[];
};
export type SectionItemType = {
id: string;
backgroundColor: string;
backgroundImage?: string;
texts: TextType[];
buttons: ButtonType[];
images: ImageType[];
alignment?: HorizontalAlignment;
verticalAlignment?: VerticalAlignment;
style?: {
borderRadius?: number;
padding?: string;
fontFamily?: string;
backgroundSize?: string;
backgroundRepeat?: string;
backgroundPosition?: string;
};
};
export type TextType = {
id: string;
text: string;
fontSize?: number;
fontFamily?: string;
color?: string;
alignment?: HorizontalAlignment;
verticalAlignment?: VerticalAlignment;
};
export type ButtonType = {
id: string;
text: string;
link: string;
size: ButtonSize;
isBorder: boolean;
borderSize: number;
textColor: string;
backgroundColor: string;
borderColor: string;
alignment?: HorizontalAlignment;
verticalAlignment?: VerticalAlignment;
};
export type ImageType = {
id: string;
src: string;
alt?: string;
size: ImageSize;
width?: string;
height?: string;
alignment?: HorizontalAlignment;
verticalAlignment?: VerticalAlignment;
};
export type PersonalityStore = {
data: PersonalityDataType;
activeSection: SectionName | "";
activeItemIndex: number;
setActiveSection: (section: SectionName) => void;
setActiveItemIndex: (index: number) => void;
setItems: (
sectionKey: keyof PersonalityDataType,
items: SectionItemType[]
) => void;
addItem: (
sectionKey: keyof PersonalityDataType,
item: Omit<SectionItemType, "id">
) => void;
updateItem: (
sectionKey: keyof PersonalityDataType,
id: string,
updates: Partial<SectionItemType>
) => void;
updateActiveItem: (updates: Partial<SectionItemType>) => void;
removeItem: (sectionKey: keyof PersonalityDataType, id: string) => void;
setColumnsCount: (
sectionKey: keyof PersonalityDataType,
columnsCount: number
) => void;
addText: (
sectionKey: keyof PersonalityDataType,
itemId: string,
text: Omit<TextType, "id">
) => void;
addTextToActiveItem: (text: Omit<TextType, "id">) => void;
addButton: (
sectionKey: keyof PersonalityDataType,
itemId: string,
button: Omit<ButtonType, "id">
) => void;
addButtonToActiveItem: (button: Omit<ButtonType, "id">) => void;
updateText: (
sectionKey: keyof PersonalityDataType,
itemId: string,
textId: string,
updates: Partial<TextType>
) => void;
updateButton: (
sectionKey: keyof PersonalityDataType,
itemId: string,
buttonId: string,
updates: Partial<ButtonType>
) => void;
addImage: (
sectionKey: keyof PersonalityDataType,
itemId: string,
image: Omit<ImageType, "id">
) => void;
addImageToActiveItem: (image: Omit<ImageType, "id">) => void;
updateImage: (
sectionKey: keyof PersonalityDataType,
itemId: string,
imageId: string,
updates: Partial<ImageType>
) => void;
};
+38
View File
@@ -0,0 +1,38 @@
import Textarea from '@/components/Textarea'
import UploadBox from '@/components/UploadBox'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
const Signture: FC = () => {
const { t } = useTranslation()
return (
<div className='bg-white rounded-4xl p-8 mt-9'>
<div className='mt-9'>
<div className='flex xl:flex-row gap-4 flex-col justify-between pb-10'>
<div className='flex-1'>
<div className='xl:text-lg'>
{t('setting.signature')}
</div>
<div className='xl:text-sm text-xs text-description mt-1.5'>
{t('setting.signature_description')}
</div>
</div>
<div className='flex-1 mt-4 xl:mt-0'>
<UploadBox
label={t('setting.upload_sign')}
/>
<div className='xl:mt-8 mt-5'>
<Textarea
label={t('setting.description_sign')}
/>
</div>
</div>
</div>
</div>
</div>
)
}
export default Signture
+20
View File
@@ -0,0 +1,20 @@
import { FC } from 'react'
import { Paths } from '@/utils/Paths'
import { Routes, Route } from 'react-router-dom'
import Setting from '@/pages/setting/Setting'
import Home from '@/pages/home/Home'
const AppRouter: FC = () => {
return (
<Routes>
<Route path='/' element={<Home />} />
<Route path={Paths.setting} element={<Setting />} />
<Route path={Paths.settingMailServer} element={<Setting />} />
<Route path={Paths.settingDomain} element={<Setting />} />
<Route path={Paths.settingAddress} element={<Setting />} />
<Route path={Paths.settingPersonality} element={<Setting />} />
<Route path={Paths.settingSignature} element={<Setting />} />
</Routes>
)
}
export default AppRouter
+136
View File
@@ -0,0 +1,136 @@
// import {
// Element3,
// Element4,
// Home2,
// Messages3,
// NotificationStatus,
// } from "iconsax-react";
// import { FC } from "react";
// import { useTranslation } from "react-i18next";
// // import { Pages } from "../config/Pages";
// import { Link, useLocation } from "react-router-dom";
// const Footer: FC = () => {
// const { t } = useTranslation("global");
// const location = useLocation();
// const isActive = (path: string) => location.pathname === path;
// return (
// <div className="xl:hidden">
// <div className="h-[60px] "></div>
// <div className="fixed z-10 bottom-2 right-3 left-3 bg-white h-[60px] rounded-2xl flex justify-between items-center px-3">
// <Link to={Pages.services.mine}>
// <div
// className={`text-description w-[70px] flex flex-col items-center gap-1.5 ${
// isActive(Pages.dashboard) ? "text-black" : ""
// }`}
// >
// <Element4
// className="size-5"
// color={isActive(Pages.services.mine) ? "black" : "#8C90A3"}
// variant={isActive(Pages.services.mine) ? "Bold" : "Linear"}
// />
// <div
// className={`text-[10px] ${
// isActive(Pages.services.mine) ? "text-black" : ""
// }`}
// >
// {t("footer.my_services")}
// </div>
// </div>
// </Link>
// <Link to={Pages.services.other}>
// <div
// className={`text-description w-[70px] flex flex-col items-center gap-1.5 ${
// isActive(Pages.services.other) ? "text-black" : ""
// }`}
// >
// <Element3
// className="size-5"
// color={isActive(Pages.services.other) ? "black" : "#8C90A3"}
// variant={isActive(Pages.services.other) ? "Bold" : "Linear"}
// />
// <div
// className={`text-[10px] ${
// isActive(Pages.services.other) ? "text-black" : ""
// }`}
// >
// {t("footer.other_services")}
// </div>
// </div>
// </Link>
// <Link to={Pages.dashboard}>
// <div
// className={`text-description w-[70px] flex flex-col items-center gap-1.5 ${
// isActive(Pages.services.mine) ? "text-black" : ""
// }`}
// >
// <div className="bg-white p-1 rounded-full -mt-7">
// <div
// className={`bg-black flex justify-center items-center size-10 rounded-full ${
// isActive(Pages.services.mine) ? "bg-black" : ""
// }`}
// >
// <Home2
// className="size-5"
// color={isActive(Pages.dashboard) ? "white" : "white"}
// variant={isActive(Pages.dashboard) ? "Bold" : "Linear"}
// />
// </div>
// </div>
// <div
// className={`text-[10px] ${
// isActive(Pages.services.mine) ? "text-black" : ""
// }`}
// >
// {t("footer.home")}
// </div>
// </div>
// </Link>
// <Link to={Pages.announcement.list}>
// <div
// className={`text-description w-[70px] flex flex-col items-center gap-1.5 ${
// isActive(Pages.announcement.list) ? "text-black" : ""
// }`}
// >
// <NotificationStatus
// className="size-5"
// color={isActive(Pages.announcement.list) ? "black" : "#8C90A3"}
// variant={isActive(Pages.announcement.list) ? "Bold" : "Linear"}
// />
// <div
// className={`text-[10px] ${
// isActive(Pages.announcement.list) ? "text-black" : ""
// }`}
// >
// {t("footer.announcements")}
// </div>
// </div>
// </Link>
// <Link to={Pages.ticket.list}>
// <div
// className={`text-description w-[70px] flex flex-col items-center gap-1.5 ${
// isActive(Pages.ticket.list) ? "text-black" : ""
// }`}
// >
// <Messages3
// className="size-5"
// color={isActive(Pages.ticket.list) ? "black" : "#8C90A3"}
// variant={isActive(Pages.ticket.list) ? "Bold" : "Linear"}
// />
// <div
// className={`text-[10px] ${
// isActive(Pages.ticket.list) ? "text-black" : ""
// }`}
// >
// {t("footer.tickets")}
// </div>
// </div>
// </Link>
// </div>
// </div>
// );
// };
// export default Footer;
+174
View File
@@ -0,0 +1,174 @@
import { FC, useEffect, useState } from 'react'
import Input from '../components/Input'
import { Element3, HambergerMenu, Wallet } from 'iconsax-react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from 'react-router-dom'
import { Paths } from '@/utils/Paths'
// import Notifications from '../pages/notification/Notification'
import { useSharedStore } from './store/sharedStore'
// import { useGetProfile } from '../pages/profile/hooks/useProfileData'
// import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
// import { useGetWalletBalance } from '../pages/wallet/hooks/useWalletData'
// import { NumberFormat } from '../config/func'
const Header: FC = () => {
const location = useLocation();
const [popoverKey, setPopoverKey] = useState(0);
const { t } = useTranslation('global')
const { setOpenSidebar, openSidebar } = useSharedStore()
// const { data } = useGetProfile()
// const getWalletBalance = useGetWalletBalance()
console.log(popoverKey);
useEffect(() => {
setPopoverKey((prevKey) => prevKey + 1);
}, [location.pathname]);
return (
<div className='fixed z-10 right-2 left-2 md:right-4 md:left-4 xl:right-[285px] top-2 md:top-4 xl:h-16 h-12 flex items-center px-3 md:px-6 bg-white justify-between rounded-[20px] md:rounded-[32px] xl:w-[calc(100%-305px)]'>
<div className='min-w-[200px] md:min-w-[270px] hidden xl:block'>
<Input
variant='search'
placeholder={t('header.search')}
className='bg-secondary border-none'
/>
</div>
<div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'>
<HambergerMenu size={24} color='black' />
</div>
{/* <img src={LogoImage} className='h-6 xl:hidden block absolute right-0 left-0 mx-auto' /> */}
<div className='flex xl:gap-6 gap-3 md:gap-4 items-center'>
<Link to={Paths.home}>
<Element3 color='black' className='size-4 md:size-[17px] xl:size-[18px]' />
</Link>
<Link className='xl:hidden' to={Paths.home}>
<Wallet className='size-4 md:size-[17px] xl:size-[18px]' color='black' />
</Link>
<Link className='hidden xl:block' to={Paths.home}>
<div className='flex items-center h-8 pl-2 rounded-full bg-[#EEF0F7]'>
<div className='px-3 text-xs'>
{/* {NumberFormat(getWalletBalance.data?.data?.balance) + ' ' + t('toman')} */}
</div>
<div className='size-[26px] flex justify-center items-center bg-white rounded-xl'>
<Wallet className='xl:size-[18px] size-[17px]' color='black' />
</div>
</div>
</Link>
{/* <Notifications /> */}
{/* {
data && (
<Popover className="relative" key={popoverKey}>
<PopoverButton >
<div className='flex gap-2 items-center mt-2.5'>
<div className='size-6 rounded-full bg-description overflow-hidden'>
<img src={data?.data?.user?.profilePic ? data?.data?.user?.profilePic : AvatarImage} className='size-full object-cover' />
</div>
<div className='xl:flex hidden gap-1 items-center'>
<div className='text-xs'>
{data?.data?.user?.firstName + ' ' + data?.data?.user?.lastName}
</div>
<ArrowDown2 size={14} color='#8C90A3' />
</div>
</div>
</PopoverButton>
<PopoverPanel style={{ minHeight: window.innerWidth < 1140 ? window.innerHeight : undefined }} anchor="bottom" className="flex xl:ml-6 overflow-auto flex-col gap-3 bg-white boxShadow xl:mt-7 z-30 py-4 text-xs rounded-2.5 xl:w-[300px] -mt-[50px] w-full fixed xl:h-fit h-full shadow-md">
<div className='absolute xl:hidden top-6 left-6'>
<CloseCircle onClick={() => setPopoverKey((prevKey) => prevKey + 1)} size={20} color='black' />
</div>
<Link to={Pages.profile} className='flex flex-col gap-2 items-center justify-center border-b border-[#EAEDF5] pb-4'>
<div className='size-14 rounded-full overflow-hidden'>
<img src={data?.data?.user?.profilePic ? data?.data?.user?.profilePic : AvatarImage} className='size-full object-cover' />
</div>
<div>
{data?.data?.user?.firstName + ' ' + data?.data?.user?.lastName}
</div>
<div className='text-description'>
{data?.data?.user?.email}
</div>
</Link>
<div className='pb-6 px-6 border-b border-[#EAEDF5]'>
<div className='flex justify-between items-center'>
<SideBarItem
icon={<Wallet size={20} color='black' />}
title={t('header.wallet')}
link={Pages.wallet}
isActive
isWithoutLine
/>
<div className='flex xl:hidden items-center mt-4 h-8 pl-2 rounded-full bg-[#EEF0F7]'>
<div className='ps-3'>{t('home.balance')}</div>
<div className='px-3 text-xs'>
{NumberFormat(getWalletBalance.data?.data?.balance) + ' ' + t('toman')}
</div>
</div>
</div>
<SideBarItem
icon={<Card color={'black'} size={20} />}
title={t('sidebar.transactions')}
isActive
link={Pages.transactions}
isWithoutLine
/>
<SideBarItem
icon={<TicketDiscount color={'black'} size={20} />}
title={t('header.discounts')}
isActive
link={Pages.discounts}
isWithoutLine
/>
<SideBarItem
icon={<Receipt1 color={'black'} size={20} />}
title={t('header.financial_info')}
isActive
link={Pages.financial}
isWithoutLine
/>
<SideBarItem
icon={<ProfileCircle color={'black'} size={20} />}
title={t('header.profile')}
isActive
link={Pages.profile}
isWithoutLine
/>
<SideBarItem
icon={<Setting2 color={'black'} size={20} />}
title={t('header.setting')}
isActive
link={Pages.setting}
isWithoutLine
/>
</div>
<div className='px-6 -mt-[2px]'>
<SideBarItem
icon={<Logout color={'black'} size={20} />}
title={t('sidebar.logout')}
isActive
link={Pages.setting}
isWithoutLine
isLogout
/>
</div>
</PopoverPanel>
</Popover>
)
} */}
</div>
</div>
)
}
export default Header
+46
View File
@@ -0,0 +1,46 @@
import { FC } from 'react'
import SideBar from './SideBar'
import AppRouter from '@/router/AppRouter'
import Header from './Header'
import Button from '@/components/Button'
import { Add } from 'iconsax-react'
import { useTranslation } from 'react-i18next'
import { useSharedStore } from './store/sharedStore'
const Main: FC = () => {
const { t } = useTranslation()
const { setOpenSidebar, setOpenNewMessage } = useSharedStore()
return (
<div className='p-2 md:p-4 overflow-hidden'>
<SideBar />
<Header />
<div className='flex-1 ms-0 xl:ms-[269px] mt-[68px] xl:mt-[81px]'>
<div className='overflow-auto w-full max-h-[calc(100vh-113px)] md:max-h-[calc(100vh-113px)]'>
<div className='pb-20 md:pb-24 xl:pb-20'>
<AppRouter />
</div>
</div>
</div>
<div className='flex fixed bottom-4 xl:hidden right-0 justify-center mt-10 md:mt-14 px-4'>
<Button
className='bg-secondary font-normal text-primary w-fit px-4 md:px-6 text-sm'
onClick={() => {
setOpenSidebar(false)
setOpenNewMessage(true)
}}
>
<div className='flex gap-2 items-center'>
<Add size={18} color='black' />
<div>{t('sidebar.new_message')}</div>
</div>
</Button>
</div>
</div>
)
}
export default Main
+95
View File
@@ -0,0 +1,95 @@
import { FC } from 'react'
import LogoImage from '../assets/images/logo.svg'
import { Brush2, Global, Logout, PenClose, People, Setting3 } from 'iconsax-react'
import SideBarItem from './SideBarItem'
import { useLocation } from 'react-router-dom'
import { useSharedStore } from './store/sharedStore'
import { clx } from '../helpers/utils'
import { useTranslation } from 'react-i18next'
import { Paths } from '@/utils/Paths'
const SideBar: FC = () => {
const { t } = useTranslation()
const { openSidebar, setOpenSidebar } = useSharedStore()
const location = useLocation()
const isActive = (path: string) => location.pathname === path
const iconSizeSideBar = 20
return (
<>
{
openSidebar && <div className='fixed top-0 left-0 right-0 bottom-0 bg-black/50 z-20' onClick={() => setOpenSidebar(false)} />
}
<div
className={clx(
'fixed xl:flex flex translate-x-[400px] xl:translate-x-0 transition-all ease-in-out opacity-0 invisible xl:visible xl:opacity-100 xl:right-2 xl:md:right-4 right-0 xl:top-2 xl:md:top-4 top-0 xl:bottom-2 xl:md:bottom-4 bottom-0 xl:rounded-[20px] xl:md:rounded-[32px] w-[250px] sm:w-[280px] xl:w-[250px] bg-white flex-col py-8 md:py-10 xl:py-12',
openSidebar && 'opacity-100 visible -translate-x-[0px] flex z-40'
)}
>
<div className='flex justify-center px-4'>
<img src={LogoImage} className='w-[120px] md:w-[140px]' />
</div>
<div className='flex-1 flex flex-col h-full overflow-y-auto no-scrollbar min-h-0'>
{/* <div className='mt-8 md:mt-10 px-8 md:px-12 text-[#C3C7DD] text-sm font-bold'>
{t('setting.title')}
</div> */}
<div className='text-xs text-[#8C90A3] mt-14'>
<SideBarItem
icon={<Setting3 variant={isActive(Paths.settingMailServer) ? 'Bold' : 'Outline'} color={isActive(Paths.settingMailServer) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('setting.mail_server')}
isActive={isActive(Paths.settingMailServer)}
link={Paths.settingMailServer}
/>
<SideBarItem
icon={<Global variant={isActive(Paths.settingDomain) ? 'Bold' : 'Outline'} color={isActive(Paths.settingDomain) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('setting.domain')}
isActive={isActive(Paths.settingDomain)}
link={Paths.settingDomain}
/>
<SideBarItem
icon={<People variant={isActive(Paths.settingAddress) ? 'Bold' : 'Outline'} color={isActive(Paths.settingAddress) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('setting.address')}
isActive={isActive(Paths.settingAddress)}
link={Paths.settingAddress}
/>
<SideBarItem
icon={<Brush2 variant={isActive(Paths.settingPersonality) ? 'Bold' : 'Outline'} color={isActive(Paths.settingPersonality) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('setting.personality')}
isActive={isActive(Paths.settingPersonality)}
link={Paths.settingPersonality}
/>
<SideBarItem
icon={<PenClose variant={isActive(Paths.settingSignature) ? 'Bold' : 'Outline'} color={isActive(Paths.settingSignature) ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('setting.signature')}
isActive={isActive(Paths.settingSignature)}
link={Paths.settingSignature}
/>
</div>
<div className='flex-1 flex flex-col justify-end mt-10 md:mt-14 pb-6 md:pb-8'>
<div className='text-xs text-[#8C90A3]'>
<SideBarItem
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('sidebar.logout')}
isActive={isActive('logout')}
link={`#`}
isLogout
/>
</div>
</div>
</div>
</div>
</>
)
}
export default SideBar
+37
View File
@@ -0,0 +1,37 @@
import { FC, ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { clx } from '../helpers/utils'
type Props = {
icon: ReactNode,
title: string,
isActive: boolean,
link: string,
isLogout?: boolean,
isWithoutLine?: boolean
}
const SideBarItem: FC<Props> = (props: Props) => {
return (
<Link to={props.link} className='flex text-xs gap-9 mt-4'>
{
!props.isWithoutLine &&
<div className={clx(
'w-1 bg-black h-6',
!props.isActive && 'invisible'
)}></div>
}
<div className='flex gap-3 items-center'>
{props.icon}
<div className={props.isActive ? 'text-black' : ''}>
{props.title}
</div>
</div>
</Link>
)
}
export default SideBarItem

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