base code with auth
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# Local development - for developers on their local machines
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
+24
@@ -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?
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
+13
@@ -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>saas_frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+4418
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "saas_frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"i18next": "^25.7.4",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet-control-geocoder": "^3.3.1",
|
||||||
|
"leaflet.fullscreen": "^5.3.0",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.50.0",
|
||||||
|
"react-i18next": "^16.5.3",
|
||||||
|
"react-router-dom": "^7.12.0",
|
||||||
|
"react-toastify": "^11.0.5",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
import { ThemeProvider } from "./context/ThemeContext";
|
||||||
|
import AppRoutes from "./routes";
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type { AuthUser, SigninRequest, SignupRequest, TokenResponse } from "./AuthTypes";
|
||||||
|
|
||||||
|
type AuthApiOptions = {
|
||||||
|
tenantId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogoutResponse = {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateProfilePayload = {
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTenantHeader = (tenantId?: string) =>
|
||||||
|
tenantId ? { headers: { "X-Tenant-ID": tenantId } } : undefined;
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
signin: (payload: SigninRequest, options?: AuthApiOptions) =>
|
||||||
|
apiClient.post<TokenResponse>(
|
||||||
|
"/api/auth/signin",
|
||||||
|
payload,
|
||||||
|
{ ...withTenantHeader(options?.tenantId), successMessage: "Signed in successfully", errorMessage: "Failed to sign in" }
|
||||||
|
),
|
||||||
|
signup: (payload: SignupRequest, options?: AuthApiOptions) =>
|
||||||
|
apiClient.post<AuthUser>(
|
||||||
|
"/api/auth/signup",
|
||||||
|
payload,
|
||||||
|
{ ...withTenantHeader(options?.tenantId), successMessage: "Account created", errorMessage: "Failed to create account" }
|
||||||
|
),
|
||||||
|
logout: () => apiClient.post<LogoutResponse>("/api/auth/logout", { successMessage: "Signed out", errorMessage: "Failed to sign out" }),
|
||||||
|
me: () => apiClient.get<AuthUser>("/api/auth/me"),
|
||||||
|
resetPassword: (oldPassword: string, newPassword: string) =>
|
||||||
|
apiClient.post<{ message: string }>("/api/auth/reset-password", {
|
||||||
|
old_password: oldPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
}, { successMessage: "Password updated", errorMessage: "Failed to update password" }),
|
||||||
|
|
||||||
|
forgotPassword: (email: string) =>
|
||||||
|
apiClient.post<{ message: string }>("/api/auth/forgot-password", { email }, {
|
||||||
|
successMessage: "If email exists, OTP sent",
|
||||||
|
errorMessage: "Failed to process request"
|
||||||
|
}),
|
||||||
|
|
||||||
|
verifyOtp: (email: string, otp: string) =>
|
||||||
|
apiClient.post<{ message: string }>("/api/auth/verify-otp", { email, otp }, {
|
||||||
|
successMessage: "OTP Verified",
|
||||||
|
errorMessage: "Invalid OTP"
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetPasswordWithOtp: (email: string, otp: string, newPassword: string) =>
|
||||||
|
apiClient.post<{ message: string }>("/api/auth/reset-password-otp", {
|
||||||
|
email,
|
||||||
|
otp,
|
||||||
|
new_password: newPassword
|
||||||
|
}, { successMessage: "Password reset successfully", errorMessage: "Failed to reset password" }),
|
||||||
|
|
||||||
|
updateProfile: (userId: string, payload: UpdateProfilePayload) =>
|
||||||
|
apiClient.put<AuthUser>(`/api/auth/update/${userId}`, payload, {
|
||||||
|
successMessage: "Profile updated successfully",
|
||||||
|
errorMessage: "Failed to update profile",
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateLanguage: (userId: string, language: string) =>
|
||||||
|
apiClient.patch<AuthUser>(`/api/auth/update/${userId}/language`, { preferred_language: language }, {
|
||||||
|
silent: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
refresh: (refreshToken: string) =>
|
||||||
|
apiClient.post<TokenResponse>("/api/auth/refresh", {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export type Access = {
|
||||||
|
id: string;
|
||||||
|
access_code: string;
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Role = {
|
||||||
|
id: string;
|
||||||
|
role_name: string;
|
||||||
|
accesses: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name?: string | null;
|
||||||
|
phone_number?: string | null;
|
||||||
|
status?: string;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
tenant_name?: string | null;
|
||||||
|
tenant_logo_url?: string | null;
|
||||||
|
preferred_language?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
role?: Role | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SigninRequest = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SignupRequest = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TokenResponse = {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
|
user: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiError = {
|
||||||
|
detail?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Mail, Lock, } from "lucide-react";
|
||||||
|
import { CustomInput, CustomButton, CustomOTPInput, CustomBackButton } from "../../../components/Custom";
|
||||||
|
import { authApi } from "../AuthApi";
|
||||||
|
|
||||||
|
type Step = "email" | "otp" | "password";
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const [step, setStep] = useState<Step>("email");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSendOtp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await authApi.forgotPassword(email);
|
||||||
|
setStep("otp");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await authApi.verifyOtp(email, otp);
|
||||||
|
setStep("password");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await authApi.resetPasswordWithOtp(email, otp, newPassword);
|
||||||
|
navigate("/signin");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 [&_.text-gray-700]:!text-white">
|
||||||
|
<div className="w-full max-w-md pt-10 mx-auto">
|
||||||
|
<CustomBackButton to="/signin" tooltip="Back to Sign In" className="text-gray-400 hover:text-gray-200" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
|
||||||
|
<div className="mb-5 sm:mb-8">
|
||||||
|
<h1 className="mb-2 font-semibold text-white text-2xl">
|
||||||
|
{step === "email" && "Forgot Password?"}
|
||||||
|
{step === "otp" && "Verify OTP"}
|
||||||
|
{step === "password" && "Reset Password"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
{step === "email" && "Enter your email address to receive a verification code."}
|
||||||
|
{step === "otp" && `Enter the verification code sent to ${email}`}
|
||||||
|
{step === "password" && "Enter your new password below."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={step === "email" ? handleSendOtp : step === "otp" ? handleVerifyOtp : handleResetPassword}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{step === "email" && (
|
||||||
|
<CustomInput
|
||||||
|
label="Email Address"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
leftIcon={<Mail size={20} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "otp" && (
|
||||||
|
<CustomOTPInput
|
||||||
|
value={otp}
|
||||||
|
onChange={setOtp}
|
||||||
|
label="OTP Code"
|
||||||
|
length={6}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "password" && (
|
||||||
|
<>
|
||||||
|
<CustomInput
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter new password"
|
||||||
|
required
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
leftIcon={<Lock size={20} />}
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
required
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
leftIcon={<Lock size={20} />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<CustomButton className="w-full" size="md" variant="primary" type="submit" loading={isLoading}>
|
||||||
|
{step === "email" && "Send OTP"}
|
||||||
|
{step === "otp" && "Verify"}
|
||||||
|
{step === "password" && "Reset Password"}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/custom";
|
||||||
|
import type { SigninRequest } from "../AuthTypes";
|
||||||
|
import { useAuth } from "../../../context/AuthContext";
|
||||||
|
|
||||||
|
export default function SignInForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const { login } = useAuth(); // Retrieve login function from context
|
||||||
|
|
||||||
|
const handleSignIn = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setErrorMessage("");
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const payload: SigninRequest = {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the context login function to ensure state is updated
|
||||||
|
await login(payload, isChecked);
|
||||||
|
|
||||||
|
// Redirect to the dashboard after successful signin.
|
||||||
|
navigate("/dashboard");
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unable to sign in. Please try again.";
|
||||||
|
setErrorMessage(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 [&_.text-gray-700]:!text-white [&_label]:!text-gray-300">
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 sm:mb-8">
|
||||||
|
<h1 className="mb-2 font-semibold text-white text-2xl">
|
||||||
|
Sign In
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
Enter your email and password to sign in!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-black transition-colors bg-white rounded-lg px-7 hover:bg-gray-50 hover:text-gray-900 w-full">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M18.7511 10.1944C18.7511 9.47495 18.6915 8.94995 18.5626 8.40552H10.1797V11.6527H15.1003C15.0011 12.4597 14.4654 13.675 13.2749 14.4916L13.2582 14.6003L15.9087 16.6126L16.0924 16.6305C17.7788 15.1041 18.7511 12.8583 18.7511 10.1944Z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.1788 18.75C12.5895 18.75 14.6133 17.9722 16.0915 16.6305L13.274 14.4916C12.5201 15.0068 11.5081 15.3666 10.1788 15.3666C7.81773 15.3666 5.81379 13.8402 5.09944 11.7305L4.99473 11.7392L2.23868 13.8295L2.20264 13.9277C3.67087 16.786 6.68674 18.75 10.1788 18.75Z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.10014 11.7305C4.91165 11.186 4.80257 10.6027 4.80257 9.99992C4.80257 9.3971 4.91165 8.81379 5.09022 8.26935L5.08523 8.1534L2.29464 6.02954L2.20333 6.0721C1.5982 7.25823 1.25098 8.5902 1.25098 9.99992C1.25098 11.4096 1.5982 12.7415 2.20333 13.9277L5.10014 11.7305Z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.1789 4.63331C11.8554 4.63331 12.9864 5.34303 13.6312 5.93612L16.1511 3.525C14.6035 2.11528 12.5895 1.25 10.1789 1.25C6.68676 1.25 3.67088 3.21387 2.20264 6.07218L5.08953 8.26943C5.81381 6.15972 7.81776 4.63331 10.1789 4.63331Z"
|
||||||
|
fill="#EB4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative py-3 sm:py-5">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="p-2 text-gray-400 bg-slate-900 sm:px-5 sm:py-2">
|
||||||
|
Or
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSignIn}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CustomInput
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="info@gmail.com"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
className="!text-gray-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
className="!text-gray-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="text-sm text-red-400">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CustomCheckBox
|
||||||
|
label="Keep me logged in"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e) => setIsChecked(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
to="/reset-password"
|
||||||
|
className="text-sm text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CustomButton
|
||||||
|
className="w-full"
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* <div className="mt-5">
|
||||||
|
<p className="text-sm font-normal text-center text-gray-300 sm:text-start">
|
||||||
|
Don't have an account? {""}
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
className="text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/Custom";
|
||||||
|
import type { SignupRequest } from "../AuthTypes";
|
||||||
|
import { authApi } from "../AuthApi";
|
||||||
|
|
||||||
|
export default function SignUpForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const handleSignUp = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setErrorMessage("");
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
if (!isChecked) {
|
||||||
|
// Require terms acceptance before sending the signup request.
|
||||||
|
setErrorMessage("Please accept the terms and conditions to continue.");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: SignupRequest = {
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName || undefined,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call the backend signup API.
|
||||||
|
await authApi.signup(payload);
|
||||||
|
|
||||||
|
// Signup does not return tokens, so take the user to signin.
|
||||||
|
navigate("/signin");
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unable to sign up. Please try again.";
|
||||||
|
setErrorMessage(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 w-full overflow-y-auto lg:w-1/2 no-scrollbar [&_.text-gray-700]:!text-white">
|
||||||
|
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 sm:mb-8">
|
||||||
|
<h1 className="mb-2 font-semibold text-white text-2xl">
|
||||||
|
Sign Up
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
Enter your email and password to sign up!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-black transition-colors bg-white rounded-lg px-7 hover:bg-gray-50 hover:text-gray-900 w-full">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M18.7511 10.1944C18.7511 9.47495 18.6915 8.94995 18.5626 8.40552H10.1797V11.6527H15.1003C15.0011 12.4597 14.4654 13.675 13.2749 14.4916L13.2582 14.6003L15.9087 16.6126L16.0924 16.6305C17.7788 15.1041 18.7511 12.8583 18.7511 10.1944Z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.1788 18.75C12.5895 18.75 14.6133 17.9722 16.0915 16.6305L13.274 14.4916C12.5201 15.0068 11.5081 15.3666 10.1788 15.3666C7.81773 15.3666 5.81379 13.8402 5.09944 11.7305L4.99473 11.7392L2.23868 13.8295L2.20264 13.9277C3.67087 16.786 6.68674 18.75 10.1788 18.75Z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.10014 11.7305C4.91165 11.186 4.80257 10.6027 4.80257 9.99992C4.80257 9.3971 4.91165 8.81379 5.09022 8.26935L5.08523 8.1534L2.29464 6.02954L2.20333 6.0721C1.5982 7.25823 1.25098 8.5902 1.25098 9.99992C1.25098 11.4096 1.5982 12.7415 2.20333 13.9277L5.10014 11.7305Z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.1789 4.63331C11.8554 4.63331 12.9864 5.34303 13.6312 5.93612L16.1511 3.525C14.6035 2.11528 12.5895 1.25 10.1789 1.25C6.68676 1.25 3.67088 3.21387 2.20264 6.07218L5.08953 8.26943C5.81381 6.15972 7.81776 4.63331 10.1789 4.63331Z"
|
||||||
|
fill="#EB4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign up with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative py-3 sm:py-5">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-700"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="p-2 text-gray-400 bg-slate-900 sm:px-5 sm:py-2">
|
||||||
|
Or
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSignUp}>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
|
<CustomInput
|
||||||
|
label="First Name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your first name"
|
||||||
|
required
|
||||||
|
value={firstName}
|
||||||
|
onChange={(event) => setFirstName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label="Last Name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your last name"
|
||||||
|
required
|
||||||
|
value={lastName}
|
||||||
|
onChange={(event) => setLastName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="text-sm text-red-400">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CustomCheckBox
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e) => setIsChecked(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<p className="inline-block font-normal text-gray-300 text-sm">
|
||||||
|
By creating an account means you agree to the{" "}
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
Terms and Conditions,
|
||||||
|
</span>{" "}
|
||||||
|
and our{" "}
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
Privacy Policy
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<CustomButton
|
||||||
|
className="w-full"
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<p className="text-sm font-normal text-center text-gray-300 sm:text-start">
|
||||||
|
Already have an account? {""}
|
||||||
|
<Link
|
||||||
|
to="/signin"
|
||||||
|
className="text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
import AuthLayout from "../../components/layout/AuthLayout";
|
||||||
|
import ResetPassword from "./Components/ResetPasswordForm";
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<ResetPassword />
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
import AuthLayout from "../../components/layout/AuthLayout";
|
||||||
|
import SignInForm from "./Components/SignInForm";
|
||||||
|
|
||||||
|
export default function SignIn() {
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<SignInForm />
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
import AuthLayout from "../../components/layout/AuthLayout";
|
||||||
|
import SignUpForm from "./Components/SignUpForm";
|
||||||
|
|
||||||
|
export default function SignUp() {
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<SignUpForm />
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Construction } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const { t } = useTranslation('dashboard');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[80vh] flex flex-col items-center justify-center p-4 text-center animate-fade-in">
|
||||||
|
<div className="bg-[var(--card-bg)] p-8 rounded-2xl border border-[var(--card-border)] shadow-sm max-w-lg w-full flex flex-col items-center">
|
||||||
|
<div className="bg-orange-50 p-4 rounded-full mb-6">
|
||||||
|
<Construction className="w-12 h-12 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold text-[var(--text-primary)] mb-3">
|
||||||
|
{t('title')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-[var(--text-secondary)] text-lg mb-8 leading-relaxed">
|
||||||
|
{t('message')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { authApi } from "../Authentication/AuthApi";
|
||||||
|
import { paletteApi } from "../theme/PaletteApi";
|
||||||
|
import type { ColorPalette } from "../theme/ThemeTypes";
|
||||||
|
import CustomButton from "../../components/custom/CustomButton";
|
||||||
|
import { Loader } from "../../components/custom/CustomLoader";
|
||||||
|
import { Check, User } from "lucide-react";
|
||||||
|
|
||||||
|
const ProfilePage: React.FC = () => {
|
||||||
|
const { user, refreshUser } = useAuth();
|
||||||
|
const { currentPalette, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const [palettes, setPalettes] = useState<ColorPalette[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [firstName, setFirstName] = useState(user?.first_name || "");
|
||||||
|
const [lastName, setLastName] = useState(user?.last_name || "");
|
||||||
|
const [phone, setPhone] = useState(user?.phone_number || "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPalettes = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await paletteApi.getAllPalettes();
|
||||||
|
setPalettes(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch palettes", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPalettes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update form when user data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setFirstName(user.first_name || "");
|
||||||
|
setLastName(user.last_name || "");
|
||||||
|
setPhone(user.phone_number || "");
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleThemeSelect = (palette: ColorPalette) => {
|
||||||
|
setTheme(palette);
|
||||||
|
toast.success("Theme updated successfully");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileUpdate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await authApi.updateProfile(user.id, {
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
phone_number: phone,
|
||||||
|
});
|
||||||
|
await refreshUser();
|
||||||
|
toast.success("Profile updated successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update profile", error);
|
||||||
|
toast.error("Failed to update profile");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading && palettes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<Loader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-[var(--text-primary)]">Profile Settings</h1>
|
||||||
|
<p className="text-[var(--text-secondary)] mt-2">Manage your account settings and preferences.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Information Section */}
|
||||||
|
<div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-xl p-6 shadow-sm">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="p-3 bg-blue-100 rounded-lg text-blue-600">
|
||||||
|
<User size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Personal Information</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">Update your personal details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleProfileUpdate} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-[var(--text-primary)]">First Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-[var(--text-primary)]">Last Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-[var(--text-primary)]">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={user?.email || ""}
|
||||||
|
disabled
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background-secondary)] text-[var(--text-secondary)] cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-[var(--text-primary)]">Phone Number</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<CustomButton type="submit" loading={isSaving} variant="primary">
|
||||||
|
Save Changes
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Selection Section */}
|
||||||
|
<div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-xl p-6 shadow-sm">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Theme Preferences</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">Choose a color theme for your interface.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{palettes.map((palette) => (
|
||||||
|
<button
|
||||||
|
key={palette.id}
|
||||||
|
onClick={() => handleThemeSelect(palette)}
|
||||||
|
className={`
|
||||||
|
relative flex items-center gap-3 p-3 rounded-xl border text-left transition-all duration-200
|
||||||
|
${currentPalette?.id === palette.id
|
||||||
|
? "border-[var(--primary)] bg-[var(--primary)]/5 ring-1 ring-[var(--primary)]"
|
||||||
|
: "border-[var(--card-border)] hover:border-[var(--text-secondary)]"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="w-6 h-6 rounded-full shadow-sm border border-[var(--card-border)]" style={{ backgroundColor: palette.colors.primary }} />
|
||||||
|
<div className="w-6 h-6 rounded-full shadow-sm border border-[var(--card-border)]" style={{ backgroundColor: palette.colors.sidebar_bg }} />
|
||||||
|
<div className="w-6 h-6 rounded-full shadow-sm border border-[var(--card-border)]" style={{ backgroundColor: palette.colors.header_bg }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">{palette.name}</span>
|
||||||
|
|
||||||
|
{currentPalette?.id === palette.id && (
|
||||||
|
<div className="absolute top-3 right-3 text-[var(--primary)]">
|
||||||
|
<Check size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { apiClient } from "../../lib/apiClient";
|
||||||
|
import type { ColorPalette } from "./ThemeTypes";
|
||||||
|
|
||||||
|
export const paletteApi = {
|
||||||
|
getAllPalettes: async () => {
|
||||||
|
return await apiClient.get<ColorPalette[]>("/api/theme/get");
|
||||||
|
},
|
||||||
|
|
||||||
|
getPalette: async (id: string) => {
|
||||||
|
return await apiClient.get<ColorPalette>(`/api/theme/get/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
createPalette: async (paletteData: any) => {
|
||||||
|
return await apiClient.post<ColorPalette>("/api/theme/create", paletteData);
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePalette: async (id: string, paletteData: any) => {
|
||||||
|
return await apiClient.put<ColorPalette>(`/api/theme/update/${id}`, paletteData);
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePalette: async (id: string) => {
|
||||||
|
await apiClient.delete(`/api/theme/delete/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
setTenantPalette: async (tenantId: string, paletteId: string) => {
|
||||||
|
return await apiClient.put(`/api/theme/tenants/${tenantId}/palette?palette_id=${paletteId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export interface ColorSet {
|
||||||
|
primary: string;
|
||||||
|
primary_hover: string;
|
||||||
|
|
||||||
|
sidebar_bg: string;
|
||||||
|
sidebar_text: string;
|
||||||
|
sidebar_text_muted: string;
|
||||||
|
sidebar_active_bg: string;
|
||||||
|
sidebar_active_text: string;
|
||||||
|
sidebar_hover_bg: string;
|
||||||
|
sidebar_border: string;
|
||||||
|
|
||||||
|
header_bg: string;
|
||||||
|
header_text: string;
|
||||||
|
header_border: string;
|
||||||
|
|
||||||
|
// Backgrounds
|
||||||
|
background: string;
|
||||||
|
background_secondary: string;
|
||||||
|
|
||||||
|
// Text
|
||||||
|
text_primary: string;
|
||||||
|
text_secondary: string;
|
||||||
|
|
||||||
|
// Cards / Surfaces
|
||||||
|
card_bg: string;
|
||||||
|
card_border: string;
|
||||||
|
|
||||||
|
table_header_bg: string;
|
||||||
|
table_row_hover: string;
|
||||||
|
table_border: string;
|
||||||
|
|
||||||
|
[key: string]: string; // Allow extra colors
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorPalette {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
is_default: boolean;
|
||||||
|
colors: ColorSet;
|
||||||
|
tenant_id?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
[key: string]: any; // Allow for other properties and CustomTable compatibility
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
|
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import {CustomButton} from "../../../components/custom/CustomButton";
|
||||||
|
import DataTable, { type ColumnDef } from "../../../components/custom/CustomTable";
|
||||||
|
import { CustomActionMenu, CustomActionItem, CustomStatus } from "../../../components/custom";
|
||||||
|
import { Loader } from "../../../components/custom/CustomLoader";
|
||||||
|
import { paletteApi } from "../PaletteApi";
|
||||||
|
import type { ColorPalette } from "../ThemeTypes";
|
||||||
|
import PaletteForm from "./PaletteForm";
|
||||||
|
import { useTheme } from "../../../context/ThemeContext";
|
||||||
|
|
||||||
|
const AllPalettes: React.FC = () => {
|
||||||
|
const [palettes, setPalettes] = useState<ColorPalette[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingPalette, setEditingPalette] = useState<ColorPalette | null>(null);
|
||||||
|
const { refreshTheme } = useTheme();
|
||||||
|
|
||||||
|
const fetchPalettes = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
try {
|
||||||
|
const data = await paletteApi.getAllPalettes();
|
||||||
|
setPalettes(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to fetch palettes";
|
||||||
|
setErrorMessage(message);
|
||||||
|
console.error("Failed to fetch palettes", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPalettes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingPalette(null);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (palette: ColorPalette) => {
|
||||||
|
setEditingPalette(palette);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!window.confirm("Are you sure you want to delete this palette?")) return;
|
||||||
|
try {
|
||||||
|
await paletteApi.deletePalette(id);
|
||||||
|
toast.success("Palette deleted successfully");
|
||||||
|
fetchPalettes();
|
||||||
|
} catch (error) {
|
||||||
|
// toast handled by api client
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (data: any) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (editingPalette) {
|
||||||
|
await paletteApi.updatePalette(editingPalette.id, data);
|
||||||
|
toast.success("Palette updated successfully");
|
||||||
|
} else {
|
||||||
|
await paletteApi.createPalette(data);
|
||||||
|
toast.success("Palette created successfully");
|
||||||
|
}
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchPalettes();
|
||||||
|
// Optionally refresh theme if the updated palette was the active one
|
||||||
|
refreshTheme();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<ColorPalette>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Name",
|
||||||
|
searchable: true,
|
||||||
|
render: (row) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-(--text-primary)">{row.name}</div>
|
||||||
|
{row.description && (
|
||||||
|
<div className="text-xs text-(--text-secondary)">{row.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "is_default",
|
||||||
|
header: "Default",
|
||||||
|
render: (row) => (
|
||||||
|
row.is_default ? (
|
||||||
|
<CustomStatus status="Default" variant="success" />
|
||||||
|
) : null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "colors",
|
||||||
|
header: "Preview",
|
||||||
|
render: (row) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border border-(--card-border) shadow-sm"
|
||||||
|
style={{ backgroundColor: row.colors.primary }}
|
||||||
|
title="Primary"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border border-(--card-border) shadow-sm"
|
||||||
|
style={{ backgroundColor: row.colors.sidebar_bg }}
|
||||||
|
title="Sidebar"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border border-(--card-border) shadow-sm"
|
||||||
|
style={{ backgroundColor: row.colors.header_bg }}
|
||||||
|
title="Header"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
render: (row) => (
|
||||||
|
<CustomActionMenu>
|
||||||
|
<CustomActionItem onClick={() => handleEdit(row)}>
|
||||||
|
<Edit2 size={16} className="mr-2" /> Edit Palette
|
||||||
|
</CustomActionItem>
|
||||||
|
|
||||||
|
<CustomActionItem
|
||||||
|
onClick={() => handleDelete(row.id)}
|
||||||
|
className={`${row.is_default ? "opacity-50 cursor-not-allowed" : "text-red-600 hover:text-red-700 hover:bg-red-50"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (row.is_default) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} className="mr-2" /> Delete Palette
|
||||||
|
</div>
|
||||||
|
</CustomActionItem>
|
||||||
|
</CustomActionMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-(--text-primary)">Theme Palettes</h1>
|
||||||
|
{/* <p className="text-sm text-(--text-secondary) mt-1">
|
||||||
|
Manage system color themes and default palettes.
|
||||||
|
</p> */}
|
||||||
|
</div>
|
||||||
|
<CustomButton onClick={handleCreate} leftIcon={<Plus size={18} />}>
|
||||||
|
Create Palette
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
|
||||||
|
<Loader />
|
||||||
|
</div>
|
||||||
|
) : errorMessage ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={palettes}
|
||||||
|
columns={columns}
|
||||||
|
enableSearchDropdown={true}
|
||||||
|
search="name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaletteForm
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
initialData={editingPalette}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AllPalettes;
|
||||||
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import CustomModal from "../../../components/custom/CustomModal";
|
||||||
|
import CustomInput from "../../../components/custom/CustomInput";
|
||||||
|
import { CustomButton } from "../../../components/custom/CustomButton";
|
||||||
|
import type { ColorPalette } from "../ThemeTypes";
|
||||||
|
|
||||||
|
interface PaletteFormProps {
|
||||||
|
initialData?: ColorPalette | null;
|
||||||
|
onSubmit: (data: any) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorFields = [
|
||||||
|
{ label: "Primary Color", key: "primary" },
|
||||||
|
{ label: "Primary Hover", key: "primary_hover" },
|
||||||
|
{ label: "Sidebar Background", key: "sidebar_bg" },
|
||||||
|
{ label: "Sidebar Text", key: "sidebar_text" },
|
||||||
|
{ label: "Sidebar Text Muted", key: "sidebar_text_muted" },
|
||||||
|
{ label: "Sidebar Active BG", key: "sidebar_active_bg" },
|
||||||
|
{ label: "Sidebar Active Text", key: "sidebar_active_text" },
|
||||||
|
{ label: "Sidebar Hover BG", key: "sidebar_hover_bg" },
|
||||||
|
{ label: "Sidebar Border", key: "sidebar_border" },
|
||||||
|
{ label: "Header Background", key: "header_bg" },
|
||||||
|
{ label: "Header Text", key: "header_text" },
|
||||||
|
{ label: "Header Border", key: "header_border" },
|
||||||
|
{ label: "App Background", key: "background" },
|
||||||
|
{ label: "App Secondary BG", key: "background_secondary" },
|
||||||
|
{ label: "Text Primary", key: "text_primary" },
|
||||||
|
{ label: "Text Secondary", key: "text_secondary" },
|
||||||
|
{ label: "Card Background", key: "card_bg" },
|
||||||
|
{ label: "Card Border", key: "card_border" },
|
||||||
|
{ label: "Table Header BG", key: "table_header_bg" },
|
||||||
|
{ label: "Table Row Hover", key: "table_row_hover" },
|
||||||
|
{ label: "Table Border", key: "table_border" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyColors = colorFields.reduce((acc, field) => {
|
||||||
|
acc[field.key] = "#000000";
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||||
|
initialData,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
isLoading,
|
||||||
|
isOpen,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
is_default: false,
|
||||||
|
colors: emptyColors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
reset({
|
||||||
|
name: initialData.name,
|
||||||
|
description: initialData.description || "",
|
||||||
|
is_default: initialData.is_default,
|
||||||
|
colors: initialData.colors,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
is_default: false,
|
||||||
|
colors: emptyColors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialData, isOpen, reset]);
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: any) => {
|
||||||
|
await onSubmit(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<CustomButton
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
type="submit"
|
||||||
|
form="palette-form"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
leftIcon={<Check size={16} />}
|
||||||
|
>
|
||||||
|
{initialData ? "Update Palette" : "Create Palette"}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onCancel}
|
||||||
|
title={initialData ? "Edit Palette" : "Create New Palette"}
|
||||||
|
size="lg"
|
||||||
|
footer={footer}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
key={initialData?.id || "create"}
|
||||||
|
id="palette-form"
|
||||||
|
onSubmit={handleSubmit(handleFormSubmit)}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<CustomInput
|
||||||
|
label="Palette Name"
|
||||||
|
{...register("name", { required: "Name is required" })}
|
||||||
|
error={errors.name?.message as string}
|
||||||
|
placeholder="e.g. Dark Mode"
|
||||||
|
/>
|
||||||
|
<CustomInput
|
||||||
|
label="Description"
|
||||||
|
{...register("description")}
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_default"
|
||||||
|
{...register("is_default")}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_default" className="text-sm font-medium text-gray-700">
|
||||||
|
Set as Default Palette
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 pt-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Color Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto pr-2 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{colorFields.map((field) => {
|
||||||
|
const currentColor = watch(`colors.${field.key}` as any);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="h-10 w-10 rounded border border-gray-300 cursor-pointer p-0.5"
|
||||||
|
value={currentColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(
|
||||||
|
`colors.${field.key}` as any,
|
||||||
|
e.target.value,
|
||||||
|
{ shouldDirty: true }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label={field.label}
|
||||||
|
{...register(`colors.${field.key}` as any, {
|
||||||
|
required: "Required",
|
||||||
|
})}
|
||||||
|
placeholder="#000000"
|
||||||
|
containerClassName="flex-1 mb-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CustomModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaletteForm;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Route, Routes } from "react-router-dom";
|
||||||
|
import AllPalettes from "./components/AllPalettes";
|
||||||
|
const Theme: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<AllPalettes />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Theme;
|
||||||
@@ -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 |
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useEffect, useRef, useState, useLayoutEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { MoreVertical } from "lucide-react";
|
||||||
|
|
||||||
|
interface CustomActionMenuProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomActionMenu: React.FC<CustomActionMenuProps> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (buttonRef.current && isOpen) {
|
||||||
|
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const menuRect = menuRef.current?.getBoundingClientRect();
|
||||||
|
|
||||||
|
let top = buttonRect.bottom + 4;
|
||||||
|
let left = buttonRect.right - (menuRect?.width || 160); // Default items width
|
||||||
|
|
||||||
|
// Check if it fits vertically
|
||||||
|
if (menuRect && top + menuRect.height > window.innerHeight) {
|
||||||
|
top = buttonRect.top - menuRect.height - 4; // Flip up
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it fits horizontally
|
||||||
|
if (left < 0) {
|
||||||
|
left = buttonRect.left; // Align left if no space on right
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({ top, left });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
updatePosition();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScrollOrResize = () => {
|
||||||
|
if (isOpen) updatePosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScrollOrResize, true);
|
||||||
|
window.addEventListener("resize", handleScrollOrResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", handleScrollOrResize, true);
|
||||||
|
window.removeEventListener("resize", handleScrollOrResize);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Handle click outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
isOpen &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(target) &&
|
||||||
|
!target.closest(".custom-action-menu-dropdown")
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors focus:outline-none ${isOpen ? 'bg-(--table-row-hover) text-(--text-primary)' : 'text-(--text-secondary) hover:bg-(--table-row-hover) hover:text-(--text-primary)'}`}
|
||||||
|
aria-label="Actions"
|
||||||
|
>
|
||||||
|
<MoreVertical size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="custom-action-menu-dropdown fixed z-[9999] min-w-[160px] rounded-lg bg-(--card-bg) shadow-xl border border-(--card-border) focus:outline-none"
|
||||||
|
style={{
|
||||||
|
top: position.top,
|
||||||
|
left: position.left,
|
||||||
|
}}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="py-1 divide-y divide-(--table-border)">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomActionItem: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ children, onClick, className = "" }) => (
|
||||||
|
<div
|
||||||
|
className={`group flex items-center w-full cursor-pointer px-4 py-2.5 text-sm text-(--text-primary) hover:bg-(--table-row-hover) transition-colors ${className}`}
|
||||||
|
onClick={() => {
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomActionMenu;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { MoveLeft } from "lucide-react";
|
||||||
|
|
||||||
|
interface CustomBackButtonProps {
|
||||||
|
to?: string;
|
||||||
|
label?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomBackButton: React.FC<CustomBackButtonProps> = ({
|
||||||
|
to,
|
||||||
|
label = "",
|
||||||
|
tooltip,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleBack = (e: React.MouseEvent) => {
|
||||||
|
if (!to) {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`group relative inline-flex items-center gap-2 text-gray-500 hover:text-gray-900 transition-colors cursor-pointer ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors">
|
||||||
|
<MoveLeft className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{label && <span className="text-sm font-medium">{label}</span>}
|
||||||
|
|
||||||
|
{tooltip && !label && (
|
||||||
|
<div className="absolute top-full mt-2 left-0 hidden whitespace-nowrap rounded bg-gray-800 px-2 py-1 text-xs text-white shadow-md group-hover:block z-50">
|
||||||
|
{tooltip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
return <Link to={to}>{content}</Link>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div onClick={handleBack}>{content}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomBackButton;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type ButtonVariant = "primary" | "secondary" | "outlined" | "text" | "icon";
|
||||||
|
type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
interface CustomButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomButton: React.FC<CustomButtonProps> = ({
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
loading = false,
|
||||||
|
disabled,
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseClasses =
|
||||||
|
"inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2";
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary:
|
||||||
|
"bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 disabled:bg-gray-300 dark:disabled:bg-gray-700 disabled:text-gray-500",
|
||||||
|
secondary:
|
||||||
|
"bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500 disabled:bg-gray-300 dark:disabled:bg-gray-700 disabled:text-gray-500",
|
||||||
|
outlined:
|
||||||
|
"border-2 border-blue-600 text-blue-600 bg-transparent hover:bg-blue-50 dark:hover:bg-blue-900/20 focus:ring-blue-500 disabled:border-gray-300 dark:disabled:border-gray-700 disabled:text-gray-400 disabled:hover:bg-transparent",
|
||||||
|
text: "text-blue-600 bg-transparent hover:bg-blue-50 dark:hover:bg-blue-900/20 focus:ring-blue-500 disabled:text-gray-400 disabled:hover:bg-transparent",
|
||||||
|
icon: "bg-transparent text-gray-500 focus:ring-gray-500 disabled:text-gray-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: variant === "icon" ? "p-1.5" : "px-3 py-1.5 text-sm gap-1.5",
|
||||||
|
md: variant === "icon" ? "p-2" : "px-4 py-2 text-sm gap-2",
|
||||||
|
lg: variant === "icon" ? "p-3" : "px-6 py-3 text-base gap-2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]
|
||||||
|
} ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} ${className}`}
|
||||||
|
disabled={isDisabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : leftIcon ? (
|
||||||
|
<span className="flex items-center justify-center">{leftIcon}</span>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
{rightIcon ? <span className="flex items-center justify-center">{rightIcon}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomButton;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
interface CustomCheckBoxProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomCheckBox = forwardRef<HTMLInputElement, CustomCheckBoxProps>(
|
||||||
|
({ label, className = "", disabled, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-2.5 cursor-pointer
|
||||||
|
${disabled ? "cursor-not-allowed opacity-60" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
className="peer sr-only"
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-5 h-5 border rounded
|
||||||
|
transition-all duration-200
|
||||||
|
flex items-center justify-center
|
||||||
|
border-gray-300 bg-white
|
||||||
|
peer-checked:bg-blue-600 peer-checked:border-blue-600
|
||||||
|
peer-checked:[&_svg]:opacity-100
|
||||||
|
peer-hover:border-blue-600
|
||||||
|
peer-focus:ring-2 peer-focus:ring-blue-600/20
|
||||||
|
peer-disabled:bg-gray-100 peer-disabled:border-gray-200
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
size={14}
|
||||||
|
className="text-white opacity-0 transition-opacity duration-200"
|
||||||
|
strokeWidth={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-gray-700 select-none">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomCheckBox;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import CustomButton from "./CustomButton";
|
||||||
|
import CustomModal from "./CustomModal";
|
||||||
|
|
||||||
|
type ConfirmationVariant = "default" | "danger";
|
||||||
|
|
||||||
|
interface CustomConfirmationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
variant?: ConfirmationVariant;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmText = "Confirm",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
variant = "default",
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const isDanger = variant === "danger";
|
||||||
|
const defaultTitle = isDanger ? "Delete item?" : "Confirm action";
|
||||||
|
const defaultDescription = isDanger
|
||||||
|
? "This action cannot be undone. Are you sure you want to proceed?"
|
||||||
|
: "Are you sure you want to proceed?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title ?? defaultTitle}
|
||||||
|
size="sm"
|
||||||
|
closeOnOverlayClick={!isLoading}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<CustomButton
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={isLoading}
|
||||||
|
className={
|
||||||
|
isDanger
|
||||||
|
? "bg-red-600 hover:bg-red-700 focus:ring-red-500 disabled:bg-red-300"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</CustomButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-full ${
|
||||||
|
isDanger ? "bg-red-100 text-red-600" : "bg-blue-100 text-blue-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={20} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{description ?? defaultDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomConfirmationModal;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
|
||||||
|
interface CustomDatePickerProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomDatePicker = forwardRef<HTMLInputElement, CustomDatePickerProps>(
|
||||||
|
({ label, error, className = "", disabled, required, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-1.5">
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-semibold text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative w-full">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="date"
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
w-full rounded-lg
|
||||||
|
bg-white text-black text-sm
|
||||||
|
border border-gray-300
|
||||||
|
pl-10 pr-3 py-3
|
||||||
|
outline-none
|
||||||
|
transition-all duration-200
|
||||||
|
hover:border-blue-600
|
||||||
|
focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20
|
||||||
|
disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-200
|
||||||
|
placeholder:text-gray-400
|
||||||
|
${
|
||||||
|
error
|
||||||
|
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
||||||
|
<Calendar size={18} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-500 mt-0.5">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomDatePicker;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { CalendarClock } from "lucide-react";
|
||||||
|
|
||||||
|
interface CustomDateTimePickerProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomDateTimePicker = forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
CustomDateTimePickerProps
|
||||||
|
>(({ label, error, className = "", disabled, required, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-1.5">
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-semibold text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative w-full">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="datetime-local"
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
w-full rounded-lg
|
||||||
|
bg-white text-black text-sm
|
||||||
|
border border-gray-300
|
||||||
|
pl-10 pr-3 py-3
|
||||||
|
outline-none
|
||||||
|
transition-all duration-200
|
||||||
|
hover:border-blue-600
|
||||||
|
focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20
|
||||||
|
disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-200
|
||||||
|
placeholder:text-gray-400
|
||||||
|
${
|
||||||
|
error
|
||||||
|
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
||||||
|
<CalendarClock size={18} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-500 mt-0.5">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CustomDateTimePicker;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomDropdownProps
|
||||||
|
extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
options: Option[];
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomDropdown = forwardRef<HTMLSelectElement, CustomDropdownProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
leftIcon,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-1.5">
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-semibold text-(--text-primary)">
|
||||||
|
{label}
|
||||||
|
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative w-full">
|
||||||
|
{leftIcon && (
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--text-secondary) pointer-events-none">
|
||||||
|
{leftIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
w-full rounded-lg
|
||||||
|
bg-(--background) text-(--text-primary) text-sm
|
||||||
|
border border-(--card-border)
|
||||||
|
px-3 py-3
|
||||||
|
outline-none
|
||||||
|
transition-all duration-200
|
||||||
|
appearance-none
|
||||||
|
hover:border-blue-500
|
||||||
|
focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20
|
||||||
|
disabled:bg-(--background-secondary) disabled:text-(--text-secondary) disabled:cursor-not-allowed disabled:border-(--card-border)
|
||||||
|
${leftIcon ? "pl-10" : ""}
|
||||||
|
pr-10
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{placeholder && (
|
||||||
|
<option value="" disabled hidden className="bg-(--card-bg) text-(--text-primary)">
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value} className="bg-(--card-bg) text-(--text-primary)">
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-(--text-secondary) pointer-events-none">
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomDropdown;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useRef, useState, useEffect } from "react";
|
||||||
|
import { Upload, X, File } from "lucide-react";
|
||||||
|
|
||||||
|
interface CustomFileUploaderProps {
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
maxFiles?: number;
|
||||||
|
accept?: string;
|
||||||
|
value?: File[];
|
||||||
|
onChange?: (files: File[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomFileUploader: React.FC<CustomFileUploaderProps> = ({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
multiple = false,
|
||||||
|
maxFiles,
|
||||||
|
accept,
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [previews, setPreviews] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Cleanup object URLs to avoid memory leaks
|
||||||
|
return () => {
|
||||||
|
Object.values(previews).forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [previews]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Generate previews for new files
|
||||||
|
const newPreviews: { [key: string]: string } = {};
|
||||||
|
value.forEach((file) => {
|
||||||
|
if (file.type.startsWith("image/") && !previews[file.name]) {
|
||||||
|
newPreviews[file.name] = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(newPreviews).length > 0) {
|
||||||
|
setPreviews((prev) => ({ ...prev, ...newPreviews }));
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
handleFiles(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFiles = (newFiles: File[]) => {
|
||||||
|
let updatedFiles = multiple ? [...value, ...newFiles] : newFiles;
|
||||||
|
|
||||||
|
if (maxFiles && updatedFiles.length > maxFiles) {
|
||||||
|
updatedFiles = updatedFiles.slice(0, maxFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange?.(updatedFiles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!disabled) setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (disabled) return;
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
handleFiles(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (e: React.MouseEvent, index: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (disabled) return;
|
||||||
|
const fileToRemove = value[index];
|
||||||
|
const newFiles = value.filter((_, i) => i !== index);
|
||||||
|
onChange?.(newFiles);
|
||||||
|
|
||||||
|
// Cleanup preview if it exists
|
||||||
|
if (previews[fileToRemove.name]) {
|
||||||
|
URL.revokeObjectURL(previews[fileToRemove.name]);
|
||||||
|
setPreviews((prev) => {
|
||||||
|
const newPrev = { ...prev };
|
||||||
|
delete newPrev[fileToRemove.name];
|
||||||
|
return newPrev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full flex flex-col gap-1.5 ${className}`}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-semibold text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative w-full rounded-lg border-2 border-dashed
|
||||||
|
flex flex-col items-center justify-center p-6
|
||||||
|
transition-all duration-200
|
||||||
|
${
|
||||||
|
isDragging
|
||||||
|
? "border-blue-600 bg-blue-50"
|
||||||
|
: "border-gray-300 bg-gray-50 hover:bg-gray-100"
|
||||||
|
}
|
||||||
|
${disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}
|
||||||
|
`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !disabled && inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple={multiple}
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2 text-gray-500">
|
||||||
|
<Upload size={24} />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{isDragging ? "Drop files here" : "Click or drag to upload"}
|
||||||
|
</span>
|
||||||
|
{maxFiles && (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
Max {maxFiles} file{maxFiles > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2 mt-2">
|
||||||
|
{value.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={`${file.name}-${index}`}
|
||||||
|
className="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<div className="w-10 h-10 flex-shrink-0 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden border border-gray-200">
|
||||||
|
{file.type.startsWith("image/") && previews[file.name] ? (
|
||||||
|
<img
|
||||||
|
src={previews[file.name]}
|
||||||
|
alt={file.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<File size={20} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col overflow-hidden">
|
||||||
|
<span className="text-sm font-medium text-gray-700 truncate">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{(file.size / 1024).toFixed(1)} KB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => removeFile(e, index)}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-500 transition-colors rounded-full hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomFileUploader;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Minus, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface CustomIncrementProps {
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomIncrement: React.FC<CustomIncrementProps> = ({
|
||||||
|
label,
|
||||||
|
required = false,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 1,
|
||||||
|
max = Number.POSITIVE_INFINITY,
|
||||||
|
step = 1,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const handleDecrease = () => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(Math.max(min, value - step));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncrease = () => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(Math.min(max, value + step));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-semibold text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`inline-flex h-11 items-center overflow-hidden rounded-lg border border-gray-200 bg-white ${
|
||||||
|
disabled ? "opacity-60" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Decrease quantity"
|
||||||
|
onClick={handleDecrease}
|
||||||
|
disabled={disabled || value <= min}
|
||||||
|
className="flex h-11 w-11 items-center justify-center text-gray-600 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Minus size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-[48px] text-center text-sm font-semibold text-gray-700">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Increase quantity"
|
||||||
|
onClick={handleIncrease}
|
||||||
|
disabled={disabled || value >= max}
|
||||||
|
className="flex h-11 w-11 items-center justify-center text-gray-600 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomIncrement;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useState, forwardRef } from "react";
|
||||||
|
import { Phone, Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
|
type InputType = "text" | "password" | "number" | "email" | "tel" | "date";
|
||||||
|
|
||||||
|
interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
type?: InputType;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
maxLength?: number;
|
||||||
|
phonePrefix?: string;
|
||||||
|
error?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
maxLength,
|
||||||
|
phonePrefix,
|
||||||
|
disabled,
|
||||||
|
className = "",
|
||||||
|
containerClassName = "",
|
||||||
|
error,
|
||||||
|
onInput,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const isPassword = type === "password";
|
||||||
|
const isPhone = phonePrefix !== undefined;
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const inputType = isPassword && showPassword ? "text" : type;
|
||||||
|
|
||||||
|
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||||
|
if (maxLength && (type === "number" || type === "tel")) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
if (target.value.length > maxLength) {
|
||||||
|
target.value = target.value.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onInput?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full flex flex-col gap-1.5 ${containerClassName}`}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={props.id} className="block text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{label}
|
||||||
|
{props.required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`relative w-full ${isPhone
|
||||||
|
? "flex overflow-hidden rounded-lg border border-(--card-border) bg-(--background)"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPhone && (
|
||||||
|
<div className="flex items-center gap-2 border-r border-(--card-border) bg-(--background-secondary) px-3 text-sm font-semibold text-(--text-primary)">
|
||||||
|
<Phone size={16} className="text-(--text-secondary)" />
|
||||||
|
{phonePrefix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPhone && leftIcon && (
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--text-secondary)">
|
||||||
|
{leftIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={inputType}
|
||||||
|
maxLength={
|
||||||
|
type === "number" || type === "tel" ? undefined : maxLength
|
||||||
|
}
|
||||||
|
onInput={handleInput}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
${isPhone
|
||||||
|
? "w-full px-3 py-3 text-sm text-(--text-primary) bg-(--background) outline-none placeholder-(--text-secondary)/50"
|
||||||
|
: `
|
||||||
|
w-full rounded-lg
|
||||||
|
bg-(--background) text-(--text-primary) text-sm
|
||||||
|
border ${error ? "border-red-500" : "border-(--card-border)"}
|
||||||
|
px-3 py-3
|
||||||
|
outline-none
|
||||||
|
transition-all duration-200
|
||||||
|
hover:border-blue-500
|
||||||
|
focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20
|
||||||
|
disabled:bg-(--background-secondary) disabled:text-(--text-secondary) disabled:cursor-not-allowed disabled:border-(--card-border)
|
||||||
|
placeholder-(--text-secondary)/50
|
||||||
|
${leftIcon ? "pl-10" : ""}
|
||||||
|
${rightIcon || isPassword ? "pr-10" : ""}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isPhone && isPassword ? (
|
||||||
|
<span
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-slate-400 select-none hover:text-slate-600"
|
||||||
|
>
|
||||||
|
{showPassword ? <Eye size={20} /> : <EyeOff size={20} />}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
!isPhone &&
|
||||||
|
rightIcon && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400">
|
||||||
|
{rightIcon}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomInput;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
type LoaderProps = {
|
||||||
|
label?: string;
|
||||||
|
size?: "sm" | "md" | "lg" | "xl";
|
||||||
|
variant?: "primary" | "white" | "gray";
|
||||||
|
inline?: boolean;
|
||||||
|
className?: string;
|
||||||
|
} & ComponentProps<"div">;
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
sm: "h-5 w-5 border-2",
|
||||||
|
md: "h-8 w-8 border-[3px]",
|
||||||
|
lg: "h-10 w-10 border-4",
|
||||||
|
xl: "h-14 w-14 border-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantMap = {
|
||||||
|
primary: "border-blue-500 border-l-transparent",
|
||||||
|
white: "border-white border-l-transparent",
|
||||||
|
gray: "border-gray-500 border-l-transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Loader({
|
||||||
|
label,
|
||||||
|
size = "md",
|
||||||
|
variant = "primary",
|
||||||
|
inline = false,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: LoaderProps) {
|
||||||
|
const spinnerClass = [
|
||||||
|
"rounded-full",
|
||||||
|
"animate-spin",
|
||||||
|
sizeMap[size],
|
||||||
|
variantMap[variant],
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const containerClass = [
|
||||||
|
"flex items-center gap-3",
|
||||||
|
inline ? "justify-start" : "justify-center",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={containerClass}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<div className={spinnerClass} />
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-gray-600">{label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Loader;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface CustomModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "full";
|
||||||
|
closeOnOverlayClick?: boolean;
|
||||||
|
zIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomModal: React.FC<CustomModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
size = "md",
|
||||||
|
closeOnOverlayClick = true,
|
||||||
|
zIndex = 50,
|
||||||
|
}) => {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: "max-w-sm",
|
||||||
|
md: "max-w-md",
|
||||||
|
lg: "max-w-lg",
|
||||||
|
xl: "max-w-xl",
|
||||||
|
"2xl": "max-w-2xl",
|
||||||
|
"3xl": "max-w-3xl",
|
||||||
|
"4xl": "max-w-4xl",
|
||||||
|
full: "max-w-full m-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-opacity"
|
||||||
|
style={{ zIndex }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
onClick={() => closeOnOverlayClick && onClose()}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className={`
|
||||||
|
relative w-full bg-(--card-bg) border border-(--card-border) rounded-xl shadow-xl transform transition-all
|
||||||
|
flex flex-col max-h-[90vh]
|
||||||
|
${sizes[size]}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-(--card-border)">
|
||||||
|
<h3 className="text-lg font-semibold text-(--text-primary)">{title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-(--text-secondary) hover:text-(--text-primary) hover:bg-(--background) rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<div className="px-6 py-4 bg-(--background) border-t border-(--card-border) rounded-b-xl flex justify-end gap-3">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomModal;
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { ChevronDown, X, Check } from "lucide-react";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomMultiSelectProps {
|
||||||
|
label?: string;
|
||||||
|
options: Option[];
|
||||||
|
value?: (string | number)[];
|
||||||
|
onChange?: (value: (string | number)[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select options...",
|
||||||
|
leftIcon,
|
||||||
|
disabled,
|
||||||
|
required,
|
||||||
|
className = "",
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (optionValue: string | number) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const newValue = value.includes(optionValue)
|
||||||
|
? value.filter((v) => v !== optionValue)
|
||||||
|
: [...value, optionValue];
|
||||||
|
onChange?.(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeValue = (e: React.MouseEvent, optionValue: string | number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (disabled) return;
|
||||||
|
onChange?.(value.filter((v) => v !== optionValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedOptions = options.filter((opt) => value.includes(opt.value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-1.5" ref={containerRef}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-semibold text-(--text-primary)">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
className={`
|
||||||
|
w-full rounded-lg
|
||||||
|
bg-(--background) text-(--text-primary) text-sm
|
||||||
|
border ${isOpen
|
||||||
|
? "border-blue-600 ring-2 ring-blue-600/20"
|
||||||
|
: error ? "border-red-500" : "border-(--card-border)"
|
||||||
|
}
|
||||||
|
px-3 py-0 h-[46px]
|
||||||
|
outline-none
|
||||||
|
transition-all duration-200
|
||||||
|
hover:border-blue-500
|
||||||
|
cursor-pointer
|
||||||
|
flex items-center gap-2
|
||||||
|
overflow-hidden
|
||||||
|
${disabled
|
||||||
|
? "bg-(--background-secondary) text-(--text-secondary) cursor-not-allowed border-(--card-border)"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${leftIcon ? "pl-10" : ""}
|
||||||
|
pr-10
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{leftIcon && (
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--text-secondary) pointer-events-none">
|
||||||
|
{leftIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden w-full h-full">
|
||||||
|
{selectedOptions.length === 0 ? (
|
||||||
|
<span className="text-(--text-secondary)/50 text-nowrap">{placeholder}</span>
|
||||||
|
) : selectedOptions.length === 1 ? (
|
||||||
|
<span
|
||||||
|
key={selectedOptions[0].value}
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs font-medium border border-blue-100 dark:border-blue-800 min-w-0"
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedOptions[0].label}</span>
|
||||||
|
<X
|
||||||
|
size={14}
|
||||||
|
className="cursor-pointer hover:text-blue-900 dark:hover:text-blue-200 shrink-0"
|
||||||
|
onClick={(e) => removeValue(e, selectedOptions[0].value)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs font-medium border border-blue-100 dark:border-blue-800">
|
||||||
|
{selectedOptions.length} Selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-(--text-secondary) pointer-events-none">
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && !disabled && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-(--card-bg) border border-(--card-border) rounded-lg shadow-lg max-h-60 overflow-hidden flex flex-col py-1">
|
||||||
|
<div className="px-2 py-1 border-b border-(--card-border)">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
className="w-full px-2 py-1 text-sm bg-transparent outline-none text-(--text-primary) placeholder-(--text-secondary)/50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
value={searchTerm}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto max-h-48">
|
||||||
|
{options
|
||||||
|
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-(--text-secondary)">
|
||||||
|
No options available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options
|
||||||
|
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
.map((option) => {
|
||||||
|
const isSelected = value.includes(option.value);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleSelect(option.value)}
|
||||||
|
className={`
|
||||||
|
px-3 py-2 text-sm cursor-pointer flex items-center justify-between
|
||||||
|
${isSelected
|
||||||
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400"
|
||||||
|
: "text-(--text-primary) hover:bg-(--table-row-hover)"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{isSelected && <Check size={16} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomMultiSelect;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import React, { useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface CustomOTPInputProps {
|
||||||
|
length?: number;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomOTPInput: React.FC<CustomOTPInputProps> = ({
|
||||||
|
length = 6,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const inputs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputs.current[0]) {
|
||||||
|
inputs.current[0].focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
index: number
|
||||||
|
) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (isNaN(Number(val))) return;
|
||||||
|
|
||||||
|
const newOtp = value.split("");
|
||||||
|
newOtp[index] = val.substring(val.length - 1);
|
||||||
|
const combinedOtp = newOtp.join("");
|
||||||
|
onChange(combinedOtp);
|
||||||
|
|
||||||
|
// Move to next input if value is entered
|
||||||
|
if (val && index < length - 1 && inputs.current[index + 1]) {
|
||||||
|
inputs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (
|
||||||
|
e: React.KeyboardEvent<HTMLInputElement>,
|
||||||
|
index: number
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
e.key === "Backspace" &&
|
||||||
|
!value[index] &&
|
||||||
|
index > 0 &&
|
||||||
|
inputs.current[index - 1]
|
||||||
|
) {
|
||||||
|
// Move to previous input on backspace if current is empty
|
||||||
|
inputs.current[index - 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pastedData = e.clipboardData.getData("text").slice(0, length);
|
||||||
|
if (/^\d+$/.test(pastedData)) {
|
||||||
|
onChange(pastedData);
|
||||||
|
// Focus the last filled input or the next empty one
|
||||||
|
const nextIndex = Math.min(pastedData.length, length - 1);
|
||||||
|
inputs.current[nextIndex]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-semibold text-white">{label}</label>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 justify-between sm:justify-start">
|
||||||
|
{Array.from({ length }).map((_, index) => (
|
||||||
|
<input
|
||||||
|
key={index}
|
||||||
|
ref={(el) => { inputs.current[index] = el }}
|
||||||
|
type="text"
|
||||||
|
maxLength={1}
|
||||||
|
value={value[index] || ""}
|
||||||
|
onChange={(e) => handleChange(e, index)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
className={`w-10 h-12 sm:w-12 sm:h-14 text-center text-xl font-bold rounded-lg border outline-none transition-all duration-200
|
||||||
|
bg-white text-gray-800 border-gray-300 focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20
|
||||||
|
${
|
||||||
|
error
|
||||||
|
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomOTPInput;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
.custom-phone-input {
|
||||||
|
font-family: inherit;
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-phone-input .PhoneInputInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.custom-phone-input .PhoneInputInput:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-phone-input .PhoneInputInput:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-phone-input .PhoneInputInput::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-phone-input .PhoneInputInput:disabled {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-phone-input .PhoneInputCountry {
|
||||||
|
position: relative;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 0;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-phone-input .PhoneInputCountryIcon,
|
||||||
|
.custom-phone-input .PhoneInputCountrySelectArrow {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-phone-input.error .PhoneInputInput {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-phone-input.error .PhoneInputInput:focus {
|
||||||
|
border-color: #ef4444;
|
||||||
|
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-phone-input.disabled .PhoneInputInput {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--card-border);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { type Country, getCountries, getCountryCallingCode } from 'react-phone-number-input';
|
||||||
|
import Input from 'react-phone-number-input/input';
|
||||||
|
import en from 'react-phone-number-input/locale/en';
|
||||||
|
import { ChevronDown, Search, Check, X } from 'lucide-react';
|
||||||
|
import * as Flags from 'country-flag-icons/react/3x2';
|
||||||
|
import 'react-phone-number-input/style.css';
|
||||||
|
import './CustomPhoneInput.css';
|
||||||
|
|
||||||
|
interface CustomPhoneInputProps {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string | undefined) => void;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
defaultCountry?: Country;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CountrySelectWithSearch = ({ value, onChange, options, disabled }: any) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
if (!isOpen) {
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
return options.filter((option: any) =>
|
||||||
|
option.label && option.label.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [options, search]);
|
||||||
|
|
||||||
|
const selectedOption = options.find((opt: any) => opt.value === value);
|
||||||
|
// Use explicit flag from library if available, otherwise fallback to text
|
||||||
|
const FlagComponent = value && (Flags as any)[value] ? (Flags as any)[value] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-full px-3 flex items-center gap-2 border border-(--card-border) rounded-l-lg bg-(--background) hover:border-blue-500 transition-colors"
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{FlagComponent ? (
|
||||||
|
<FlagComponent title={selectedOption?.label} className="w-6 h-4 object-cover rounded-sm border border-gray-100" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium">{value}</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown size={14} className={`text-gray-500 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 top-full left-0 mt-1 w-[280px] bg-(--card-bg) border border-(--card-border) rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||||
|
<div className="p-2 border-b border-(--card-border) sticky top-0 bg-(--card-bg)">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search country..."
|
||||||
|
className="w-full text-sm pl-8 pr-8 py-1.5 rounded-md border border-(--card-border) bg-(--background-secondary) text-(--text-primary) outline-none focus:border-blue-500 placeholder:text-gray-400"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[200px] overflow-y-auto">
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option: any) => {
|
||||||
|
const OptionFlag = option.value && (Flags as any)[option.value] ? (Flags as any)[option.value] : null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value || 'divider'}
|
||||||
|
type="button"
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-3 hover:bg-(--table-row-hover) transition-colors ${option.value === value ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600" : "text-(--text-primary)"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.value);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{OptionFlag && <OptionFlag className="w-6 h-4 object-cover rounded-sm border border-gray-100" />}
|
||||||
|
<span className="flex-1 truncate">{option.label}</span>
|
||||||
|
{option.value === value && <Check size={14} />}
|
||||||
|
<span className="text-xs text-gray-400">+{getCountryCallingCode(option.value)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="p-3 text-center text-sm text-gray-500">No countries found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomPhoneInput = ({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required = false,
|
||||||
|
error,
|
||||||
|
placeholder = "Enter phone number",
|
||||||
|
disabled = false,
|
||||||
|
defaultCountry = "IN" as Country,
|
||||||
|
}: CustomPhoneInputProps) => {
|
||||||
|
const [country, setCountry] = useState<Country>(defaultCountry);
|
||||||
|
|
||||||
|
const handleCountryChange = (newCountry: Country) => {
|
||||||
|
setCountry(newCountry);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhoneChange = (newValue: string | undefined) => {
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const countryOptions = useMemo(() => {
|
||||||
|
return getCountries().map((country) => ({
|
||||||
|
value: country,
|
||||||
|
label: en[country],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor={name} className="text-sm font-medium text-(--text-primary)">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={`custom-phone-input ${error ? 'error' : ''} ${disabled ? 'disabled' : ''}`}>
|
||||||
|
<div className="PhoneInputCountry">
|
||||||
|
<CountrySelectWithSearch
|
||||||
|
value={country}
|
||||||
|
onChange={handleCountryChange}
|
||||||
|
options={countryOptions}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center flex-1 min-w-0 border rounded-r-lg border-l-0 border-(--card-border) bg-(--background) focus-within:ring-2 focus-within:ring-blue-600/20 focus-within:border-blue-600 hover:border-blue-500 transition-all">
|
||||||
|
<div className="pl-3 text-(--text-secondary) font-medium select-none text-sm">
|
||||||
|
+{getCountryCallingCode(country)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
country={country}
|
||||||
|
value={value}
|
||||||
|
onChange={handlePhoneChange}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full h-full p-3 bg-transparent border-none outline-none text-sm text-(--text-primary) placeholder-(--text-secondary)/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
|
||||||
|
interface CustomRadioProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomRadio = forwardRef<HTMLInputElement, CustomRadioProps>(
|
||||||
|
({ label, className = "", disabled, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-2.5 cursor-pointer
|
||||||
|
${disabled ? "cursor-not-allowed opacity-60" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="radio"
|
||||||
|
className="peer sr-only"
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-5 h-5 border rounded-full
|
||||||
|
transition-all duration-200
|
||||||
|
flex items-center justify-center
|
||||||
|
border-gray-300 bg-white
|
||||||
|
peer-checked:border-blue-600 peer-checked:bg-blue-600
|
||||||
|
peer-checked:[&>div]:opacity-100
|
||||||
|
peer-hover:border-blue-600
|
||||||
|
peer-focus:ring-2 peer-focus:ring-blue-600/20
|
||||||
|
peer-disabled:bg-gray-100 peer-disabled:border-gray-200
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full opacity-0 transition-opacity duration-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-gray-700 select-none">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomRadio;
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { ChevronDown, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomSearchableDropdownProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||||
|
label?: string;
|
||||||
|
options: Option[];
|
||||||
|
value?: string | number;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomSearchableDropdown = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
CustomSearchableDropdownProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select...",
|
||||||
|
disabled,
|
||||||
|
className = "",
|
||||||
|
required,
|
||||||
|
leftIcon,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Sync search term with selected value on mount or value update
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedOption = options.find((opt) => String(opt.value) === String(value));
|
||||||
|
if (selectedOption) {
|
||||||
|
setSearchTerm(selectedOption.label);
|
||||||
|
} else {
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
}, [value, options]);
|
||||||
|
|
||||||
|
const filteredOptions = options.filter((option) =>
|
||||||
|
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
// Revert to selected value label if closing without selection
|
||||||
|
const selectedOption = options.find((opt) => String(opt.value) === String(value));
|
||||||
|
if (selectedOption) {
|
||||||
|
setSearchTerm(selectedOption.label);
|
||||||
|
} else if (!value) {
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [value, options]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
if (e.target.value === "") {
|
||||||
|
onChange?.(""); // Clear selection if input is cleared
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen && e.key !== "Tab") {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((prev) =>
|
||||||
|
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
if (isOpen && filteredOptions[highlightedIndex]) {
|
||||||
|
handleSelect(filteredOptions[highlightedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
break;
|
||||||
|
case "Tab":
|
||||||
|
setIsOpen(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (option: Option) => {
|
||||||
|
onChange?.(String(option.value));
|
||||||
|
setSearchTerm(option.label);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange?.("");
|
||||||
|
setSearchTerm("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (!disabled) setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-1.5" ref={ref}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-medium text-(--text-primary)">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative w-full" ref={dropdownRef}>
|
||||||
|
{leftIcon && (
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--text-secondary) pointer-events-none z-[99999]">
|
||||||
|
{leftIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
w-full rounded-lg
|
||||||
|
bg-(--background) text-(--text-primary) text-sm
|
||||||
|
border ${props.error ? 'border-red-500' : 'border-(--card-border)'}
|
||||||
|
py-3
|
||||||
|
outline-none
|
||||||
|
transition-all duration-200
|
||||||
|
hover:border-blue-500
|
||||||
|
focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20
|
||||||
|
disabled:bg-(--background-secondary) disabled:text-(--text-secondary) disabled:cursor-not-allowed disabled:border-(--card-border)
|
||||||
|
placeholder-(--text-secondary)/50
|
||||||
|
${leftIcon ? "pl-10" : "px-3"}
|
||||||
|
pr-16
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
className="text-(--text-secondary) hover:text-(--text-primary) cursor-pointer disabled:cursor-not-allowed p-0.5"
|
||||||
|
disabled={disabled}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{searchTerm && !disabled && (
|
||||||
|
<button type="button" onClick={handleClear} className="text-(--text-secondary) hover:text-(--text-primary) p-0.5">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isOpen && !disabled && filteredOptions.length > 0 && (
|
||||||
|
<div className="absolute z-[999] w-full mt-2 bg-(--card-bg) border border-(--card-border) rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
|
||||||
|
{filteredOptions.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
className={`
|
||||||
|
w-full text-left px-4 py-2 text-sm
|
||||||
|
transition-colors duration-150
|
||||||
|
${String(option.value) === String(value)
|
||||||
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 font-medium"
|
||||||
|
: highlightedIndex === index
|
||||||
|
? "bg-(--table-row-hover) text-(--text-primary)"
|
||||||
|
: "text-(--text-primary) hover:bg-(--table-row-hover)"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOpen && !disabled && filteredOptions.length === 0 && (
|
||||||
|
<div className="absolute z-[999] w-full mt-2 bg-(--card-bg) border border-(--card-border) rounded-lg shadow-lg p-4 text-center text-sm text-(--text-secondary)">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
CustomSearchableDropdown.displayName = "CustomSearchableDropdown";
|
||||||
|
|
||||||
|
export default CustomSearchableDropdown;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export type StatusVariant =
|
||||||
|
| "success"
|
||||||
|
| "error"
|
||||||
|
| "warning"
|
||||||
|
| "info"
|
||||||
|
| "neutral";
|
||||||
|
|
||||||
|
interface CustomStatusProps {
|
||||||
|
status: string; // The text to display
|
||||||
|
variant?: StatusVariant; // Explicit variant
|
||||||
|
className?: string; // Additional classes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to deduce variant from common status strings if not provided
|
||||||
|
const getVariantFromStatus = (status: string): StatusVariant => {
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
|
||||||
|
if (["active", "success", "completed", "paid", "delivered", "ready to ship"].some(s => lower.includes(s))) return "success";
|
||||||
|
if (["inactive", "failed", "rejected", "error", "deleted", "banned"].some(s => lower.includes(s))) return "error";
|
||||||
|
if (["pending", "processing", "draft", "warning", "hold", "ready", "packed"].some(s => lower.includes(s))) return "warning";
|
||||||
|
if (["info", "shipped", "in_transit", "labeled"].some(s => lower.includes(s))) return "info";
|
||||||
|
|
||||||
|
return "neutral";
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomStatus: React.FC<CustomStatusProps> = ({
|
||||||
|
status,
|
||||||
|
variant,
|
||||||
|
className = ""
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation(['orders', 'common']);
|
||||||
|
const finalVariant = variant || getVariantFromStatus(status);
|
||||||
|
|
||||||
|
// Helper to map backend status (e.g. READY_TO_SHIP) to translation key (e.g. readyToShip)
|
||||||
|
const getTranslationKey = (statusStr: string) => {
|
||||||
|
const normalize = (s: string) => s.toUpperCase().replace(/\s+/g, '_');
|
||||||
|
const normalized = normalize(statusStr);
|
||||||
|
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"READY_TO_SHIP": "readyToShip",
|
||||||
|
"IN_TRANSIT": "inTransit",
|
||||||
|
"READY_FOR_TRANSIT": "readyForTransit",
|
||||||
|
"PICKUP_SCHEDULED": "pickupScheduled",
|
||||||
|
"NEW": "new",
|
||||||
|
"READY": "ready",
|
||||||
|
"SHIPPED": "shipped",
|
||||||
|
"DELIVERED": "delivered"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map[normalized]) return map[normalized];
|
||||||
|
|
||||||
|
return statusStr.toLowerCase().replace(/ (\w)/g, (_match, p1) => p1.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const translatedStatus = t(`status.${getTranslationKey(status)}`, { defaultValue: status });
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
success: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800",
|
||||||
|
error: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800",
|
||||||
|
warning: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800",
|
||||||
|
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 border-blue-200 dark:border-blue-800",
|
||||||
|
neutral: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-200 dark:border-gray-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border
|
||||||
|
${styles[finalVariant]}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{translatedStatus}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomStatus;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
|
||||||
|
interface CustomSwitchProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomSwitch = forwardRef<HTMLInputElement, CustomSwitchProps>(
|
||||||
|
({ label, className = "", disabled, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-3 cursor-pointer
|
||||||
|
${disabled ? "cursor-not-allowed opacity-60" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="relative inline-flex items-center">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
className="peer sr-only"
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-11 h-6 bg-gray-200 rounded-full
|
||||||
|
peer-focus:ring-2 peer-focus:ring-blue-600/20
|
||||||
|
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||||
|
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||||
|
after:bg-white after:border-gray-300 after:border after:rounded-full
|
||||||
|
after:h-5 after:w-5 after:transition-all
|
||||||
|
peer-checked:bg-blue-600
|
||||||
|
peer-disabled:bg-gray-100
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-gray-700 select-none">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomSwitch;
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import CustomButton from "./CustomButton";
|
||||||
|
import CustomInput from "./CustomInput";
|
||||||
|
import { Upload, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Primitive =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| Date
|
||||||
|
| Record<string, unknown>;
|
||||||
|
|
||||||
|
export type ColumnDef<T> = {
|
||||||
|
key: keyof T | string;
|
||||||
|
header: React.ReactNode;
|
||||||
|
exportHeader?: string;
|
||||||
|
render?: (row: T) => React.ReactNode;
|
||||||
|
searchable?: boolean;
|
||||||
|
cellClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataTableProps<T extends Record<string, unknown>> = {
|
||||||
|
data: T[];
|
||||||
|
columns: Array<ColumnDef<T>>;
|
||||||
|
defaultPageSize?: number;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
exportFileName?: string;
|
||||||
|
className?: string;
|
||||||
|
getRowId?: (row: T, index: number) => string | number;
|
||||||
|
filterControls?: React.ReactNode;
|
||||||
|
|
||||||
|
enableSearchDropdown?: boolean;
|
||||||
|
buildSuggestionLabel?: (row: T) => string;
|
||||||
|
onSuggestionSelect?: (row: T) => void;
|
||||||
|
maxSuggestions?: number;
|
||||||
|
highlightClassName?: string;
|
||||||
|
|
||||||
|
manualPagination?: boolean;
|
||||||
|
manualFiltering?: boolean;
|
||||||
|
totalRows?: number;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
onSearchChange?: (search: string) => void;
|
||||||
|
|
||||||
|
searchInputRef?: React.RefObject<HTMLInputElement | null>;
|
||||||
|
maxHeight?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toCSVValue(v: unknown) {
|
||||||
|
if (v == null) return "";
|
||||||
|
const text =
|
||||||
|
v instanceof Date
|
||||||
|
? v.toISOString()
|
||||||
|
: typeof v === "object"
|
||||||
|
? JSON.stringify(v)
|
||||||
|
: String(v);
|
||||||
|
const needsWrap = /[",\n]/.test(text);
|
||||||
|
return needsWrap ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCSV(fileName: string, rows: string[]) {
|
||||||
|
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<T extends Record<string, unknown>>(
|
||||||
|
props: DataTableProps<T>
|
||||||
|
) {
|
||||||
|
const { t } = useTranslation('common');
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
defaultPageSize = 10,
|
||||||
|
pageSizeOptions = [5, 10, 20, 50],
|
||||||
|
exportFileName = "export.csv",
|
||||||
|
className,
|
||||||
|
getRowId,
|
||||||
|
filterControls,
|
||||||
|
enableSearchDropdown = false,
|
||||||
|
buildSuggestionLabel,
|
||||||
|
onSuggestionSelect,
|
||||||
|
maxSuggestions = 8,
|
||||||
|
highlightClassName = "ring-2 ring-indigo-400",
|
||||||
|
manualPagination = false,
|
||||||
|
manualFiltering = false,
|
||||||
|
totalRows,
|
||||||
|
page: controlledPage,
|
||||||
|
pageSize: controlledPageSize,
|
||||||
|
search: controlledSearch,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
onSearchChange,
|
||||||
|
searchInputRef,
|
||||||
|
maxHeight = "70vh", // Default max height
|
||||||
|
isLoading,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [internalSearch, setInternalSearch] = React.useState("");
|
||||||
|
const [internalPageSize, setInternalPageSize] = React.useState(defaultPageSize);
|
||||||
|
const [internalPage, setInternalPage] = React.useState(1);
|
||||||
|
|
||||||
|
const search = manualFiltering && controlledSearch !== undefined ? controlledSearch : internalSearch;
|
||||||
|
const pageSize = manualPagination && controlledPageSize !== undefined ? controlledPageSize : internalPageSize;
|
||||||
|
const page = manualPagination && controlledPage !== undefined ? controlledPage : internalPage;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!manualPagination) {
|
||||||
|
setInternalPage(1);
|
||||||
|
}
|
||||||
|
}, [internalSearch, internalPageSize, manualPagination]);
|
||||||
|
|
||||||
|
const searchableColumns = React.useMemo(
|
||||||
|
() => columns.filter((c) => c.searchable !== false),
|
||||||
|
[columns]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCellValue = React.useCallback(
|
||||||
|
(row: T, key: ColumnDef<T>["key"]) =>
|
||||||
|
(row as Record<string, unknown>)[String(key)],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedText = (v: unknown) => {
|
||||||
|
if (v instanceof Date) return v.toISOString();
|
||||||
|
if (v == null) return "";
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = React.useMemo(() => {
|
||||||
|
if (manualFiltering) return data;
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
return data.filter((row) => {
|
||||||
|
if (!query) return true;
|
||||||
|
return searchableColumns.some((c) => {
|
||||||
|
const text = normalizedText(getCellValue(row, c.key));
|
||||||
|
return text.toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [data, getCellValue, search, searchableColumns, manualFiltering]);
|
||||||
|
|
||||||
|
const total = manualPagination && totalRows !== undefined ? totalRows : filtered.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const clampedPage = Math.min(page, totalPages);
|
||||||
|
const start = (clampedPage - 1) * pageSize;
|
||||||
|
const pageRows = manualPagination ? data : filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
const exportCurrentView = () => {
|
||||||
|
const header = columns
|
||||||
|
.map((c) =>
|
||||||
|
toCSVValue(
|
||||||
|
c.exportHeader ??
|
||||||
|
(typeof c.header === "string" ? c.header : "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.join(",");
|
||||||
|
const lines = pageRows.map((row) =>
|
||||||
|
columns
|
||||||
|
.map((c) => {
|
||||||
|
const value = c.render ? c.render(row) : getCellValue(row, c.key);
|
||||||
|
if (typeof value === "string" || typeof value === "number")
|
||||||
|
return toCSVValue(value);
|
||||||
|
return toCSVValue(getCellValue(row, c.key));
|
||||||
|
})
|
||||||
|
.join(",")
|
||||||
|
);
|
||||||
|
downloadCSV(exportFileName, [header, ...lines]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = React.useState(-1);
|
||||||
|
const [highlightedId, setHighlightedId] = React.useState<string | number | null>(null);
|
||||||
|
|
||||||
|
const defaultLabelBuilder = React.useCallback(
|
||||||
|
(row: T) => {
|
||||||
|
const parts = searchableColumns
|
||||||
|
.map((c) => normalizedText(getCellValue(row, c.key)))
|
||||||
|
.filter(Boolean);
|
||||||
|
return parts.join(" • ");
|
||||||
|
},
|
||||||
|
[getCellValue, searchableColumns]
|
||||||
|
);
|
||||||
|
|
||||||
|
const suggestionLabel = buildSuggestionLabel ?? defaultLabelBuilder;
|
||||||
|
|
||||||
|
const suggestions = React.useMemo(() => {
|
||||||
|
if (!enableSearchDropdown) return [];
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
const scored = data
|
||||||
|
.map((row, idx) => {
|
||||||
|
const label = suggestionLabel(row);
|
||||||
|
const lower = label.toLowerCase();
|
||||||
|
if (!lower.includes(q)) return null;
|
||||||
|
const starts = lower.startsWith(q) ? 0 : 1;
|
||||||
|
return { row, idx, label, score: starts };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{
|
||||||
|
row: T;
|
||||||
|
idx: number;
|
||||||
|
label: string;
|
||||||
|
score: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
scored.sort((a, b) => a.score - b.score || a.label.localeCompare(b.label));
|
||||||
|
return scored.slice(0, maxSuggestions);
|
||||||
|
}, [enableSearchDropdown, search, data, maxSuggestions, suggestionLabel]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!enableSearchDropdown) return;
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (!containerRef.current?.contains(e.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocClick);
|
||||||
|
return () => document.removeEventListener("mousedown", onDocClick);
|
||||||
|
}, [enableSearchDropdown]);
|
||||||
|
|
||||||
|
const jumpToRow = React.useCallback(
|
||||||
|
(absoluteIndex: number, row: T) => {
|
||||||
|
const targetPage = Math.max(1, Math.ceil((absoluteIndex + 1) / pageSize));
|
||||||
|
if (manualPagination && onPageChange) {
|
||||||
|
onPageChange(targetPage);
|
||||||
|
} else {
|
||||||
|
setInternalPage(targetPage);
|
||||||
|
}
|
||||||
|
const id = getRowId?.(row, absoluteIndex) ?? String(absoluteIndex);
|
||||||
|
setHighlightedId(id);
|
||||||
|
window.setTimeout(() => setHighlightedId(null), 1600);
|
||||||
|
},
|
||||||
|
[getRowId, pageSize, manualPagination, onPageChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectSuggestion = React.useCallback(
|
||||||
|
(s: { row: T; idx: number; label: string }) => {
|
||||||
|
if (manualFiltering && onSearchChange) {
|
||||||
|
onSearchChange(s.label);
|
||||||
|
} else {
|
||||||
|
setInternalSearch(s.label);
|
||||||
|
}
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
jumpToRow(s.idx, s.row);
|
||||||
|
onSuggestionSelect?.(s.row);
|
||||||
|
},
|
||||||
|
[jumpToRow, onSuggestionSelect, manualFiltering, onSearchChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!enableSearchDropdown || !showSuggestions) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "Enter" && activeIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const chosen = suggestions[activeIndex];
|
||||||
|
selectSuggestion({ row: chosen.row, idx: chosen.idx, label: chosen.label });
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-[var(--card-bg)] border-b border-[var(--card-border)] rounded-t-lg">
|
||||||
|
<div className="relative w-full max-w-sm">
|
||||||
|
<CustomInput
|
||||||
|
label=""
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
if (manualFiltering && onSearchChange) {
|
||||||
|
onSearchChange(newValue);
|
||||||
|
} else {
|
||||||
|
setInternalSearch(newValue);
|
||||||
|
}
|
||||||
|
if (enableSearchDropdown) setShowSuggestions(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={onSearchKeyDown}
|
||||||
|
onFocus={() => {
|
||||||
|
if (enableSearchDropdown && search.trim()) setShowSuggestions(true);
|
||||||
|
}}
|
||||||
|
placeholder={t('actions.search') + "..."}
|
||||||
|
className="mb-0 text-[var(--text-primary)]"
|
||||||
|
ref={searchInputRef}
|
||||||
|
/>
|
||||||
|
{enableSearchDropdown &&
|
||||||
|
showSuggestions &&
|
||||||
|
suggestions.length > 0 && (
|
||||||
|
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-md border border-[var(--card-border)] bg-[var(--card-bg)] shadow-lg">
|
||||||
|
<ul className="max-h-72 overflow-auto py-1 text-sm">
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<li
|
||||||
|
key={(getRowId?.(s.row, s.idx) ?? s.label) as React.Key}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer px-3 py-2 hover:bg-[var(--table-row-hover)]",
|
||||||
|
i === activeIndex && "bg-[var(--table-row-hover)]"
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setActiveIndex(i)}
|
||||||
|
onMouseLeave={() => setActiveIndex(-1)}
|
||||||
|
onClick={() =>
|
||||||
|
selectSuggestion({
|
||||||
|
row: s.row,
|
||||||
|
idx: s.idx,
|
||||||
|
label: s.label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="truncate text-[var(--text-primary)]">{s.label}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{filterControls}
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="sm"
|
||||||
|
onClick={exportCurrentView}
|
||||||
|
leftIcon={<Upload size={16} />}
|
||||||
|
>
|
||||||
|
{t('actions.export')}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="max-w-full overflow-x-auto overflow-y-auto relative [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-[#1D2A4B] [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-[#1D2A4B]/80 [&::-webkit-scrollbar:vertical]:hidden"
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
<table className="min-w-max w-full text-sm">
|
||||||
|
<thead className="bg-[var(--table-header-bg)] sticky top-0 z-10 shadow-sm">
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wider text-[var(--text-secondary)]">
|
||||||
|
{columns.map((c, idx) => (
|
||||||
|
<th
|
||||||
|
key={String(c.key) + idx}
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap px-6 py-3 font-medium bg-[var(--table-header-bg)] text-[var(--text-secondary)]",
|
||||||
|
c.cellClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[var(--table-border)] text-[var(--text-primary)]">
|
||||||
|
{isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="px-6 py-24 text-center text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<svg className="animate-spin h-8 w-8 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p>{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : pageRows.length > 0 ? (
|
||||||
|
pageRows.map((row, i) => {
|
||||||
|
const absoluteIndex = start + i;
|
||||||
|
const id = getRowId?.(row, absoluteIndex) ?? `${absoluteIndex}`;
|
||||||
|
const isHighlighted = highlightedId === id;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={id}
|
||||||
|
className={cn(
|
||||||
|
"bg-[var(--card-bg)] hover:bg-[var(--table-row-hover)] transition-colors",
|
||||||
|
isHighlighted && highlightClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columns.map((c, ci) => {
|
||||||
|
const content = c.render
|
||||||
|
? c.render(row)
|
||||||
|
: (getCellValue(row, c.key) as React.ReactNode);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={String(c.key) + ci}
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap px-6 py-4 text-[var(--text-primary)]",
|
||||||
|
c.cellClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content ?? ""}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="px-6 py-12 text-center text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
{t('common.noData')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-[var(--card-bg)] border-t border-[var(--card-border)] rounded-b-lg">
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t('common.showing')}{" "}
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">{total === 0 ? 0 : start + 1}</span> -{" "}
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">{start + pageRows.length}</span> {t('common.of')}{" "}
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">{total}</span> {t('common.entries')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">{t('common.rowsPerPage')}:</span>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSize = Number(e.target.value);
|
||||||
|
if (manualPagination && onPageSizeChange) {
|
||||||
|
onPageSizeChange(newSize);
|
||||||
|
} else {
|
||||||
|
setInternalPageSize(newSize);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-[var(--card-border)] text-sm py-1.5 px-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-[var(--card-bg)] text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((ps) => (
|
||||||
|
<option key={ps} value={ps}>
|
||||||
|
{ps}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newPage = Math.max(1, page - 1);
|
||||||
|
if (manualPagination && onPageChange) {
|
||||||
|
onPageChange(newPage);
|
||||||
|
} else {
|
||||||
|
setInternalPage((p: number) => Math.max(1, p - 1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={clampedPage === 1}
|
||||||
|
className="flex items-center justify-center h-9 w-9 text-sm border border-[var(--card-border)] rounded-md hover:bg-[var(--table-row-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t('common.page')} <span className="font-medium text-[var(--text-primary)]">{clampedPage}</span> {t('common.of')}{" "}
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">{totalPages}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newPage = Math.min(totalPages, page + 1);
|
||||||
|
if (manualPagination && onPageChange) {
|
||||||
|
onPageChange(newPage);
|
||||||
|
} else {
|
||||||
|
setInternalPage((p: number) => Math.min(totalPages, p + 1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={clampedPage === totalPages}
|
||||||
|
className="flex items-center justify-center h-9 w-9 text-sm border border-[var(--card-border)] rounded-md hover:bg-[var(--table-row-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataTable;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useEffect, useId, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type TabItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
badge?: string | number;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CustomTabsProps {
|
||||||
|
tabs: TabItem[];
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
onChange?: (tabId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
tabListClassName?: string;
|
||||||
|
tabClassName?: string;
|
||||||
|
activeTabClassName?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTabs: React.FC<CustomTabsProps> = ({
|
||||||
|
tabs,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
className = "",
|
||||||
|
tabListClassName = "",
|
||||||
|
tabClassName = "",
|
||||||
|
activeTabClassName = "",
|
||||||
|
contentClassName = "",
|
||||||
|
}) => {
|
||||||
|
const defaultTabId = tabs[0]?.id || "";
|
||||||
|
const [internalValue, setInternalValue] = useState(defaultValue || defaultTabId);
|
||||||
|
const activeValue = value ?? internalValue;
|
||||||
|
const baseId = useId();
|
||||||
|
|
||||||
|
const activeTab = useMemo(
|
||||||
|
() => tabs.find((tab) => tab.id === activeValue) ?? tabs[0],
|
||||||
|
[tabs, activeValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tabs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tabs.some((tab) => tab.id === activeValue)) {
|
||||||
|
setInternalValue(tabs[0].id);
|
||||||
|
}
|
||||||
|
}, [tabs, activeValue]);
|
||||||
|
|
||||||
|
const handleTabClick = (tabId: string, disabled?: boolean) => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined) {
|
||||||
|
setInternalValue(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange?.(tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full ${className}`}>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
className={`flex overflow-x-auto whitespace-nowrap scrollbar-hide pb-1 items-center gap-2 border-b border-(--card-border) ${tabListClassName}`}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTab?.id;
|
||||||
|
const tabId = `${baseId}-tab-${tab.id}`;
|
||||||
|
const panelId = `${baseId}-panel-${tab.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
id={tabId}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
aria-controls={panelId}
|
||||||
|
aria-selected={isActive}
|
||||||
|
disabled={tab.disabled}
|
||||||
|
onClick={() => handleTabClick(tab.id, tab.disabled)}
|
||||||
|
className={`
|
||||||
|
relative inline-flex items-center gap-2 rounded-t-lg border border-transparent px-4 py-2 text-sm font-medium
|
||||||
|
transition-colors duration-200
|
||||||
|
${isActive ? "border-(--card-border) text-(--text-primary)" : "text-(--text-secondary) hover:text-(--text-primary)"}
|
||||||
|
${tab.disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}
|
||||||
|
${tabClassName}
|
||||||
|
${isActive ? activeTabClassName : ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{tab.icon && <span className="text-gray-500">{tab.icon}</span>}
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.badge !== undefined && tab.badge !== null && (
|
||||||
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||||
|
{tab.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`
|
||||||
|
pointer-events-none absolute -bottom-[1px] left-3 right-3 h-0.5 rounded-full
|
||||||
|
transition-opacity duration-200
|
||||||
|
${isActive ? "bg-current opacity-100" : "bg-transparent opacity-0"}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id={`${baseId}-panel-${activeTab?.id ?? "tab"}`}
|
||||||
|
aria-labelledby={`${baseId}-tab-${activeTab?.id ?? "tab"}`}
|
||||||
|
className={`mt-4 ${contentClassName}`}
|
||||||
|
>
|
||||||
|
{activeTab?.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomTabs;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
|
||||||
|
interface CustomTextAreaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
maxLength?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
maxLength,
|
||||||
|
disabled,
|
||||||
|
className = "",
|
||||||
|
rows = 4,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-1.5">
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-semibold text-gray-700">
|
||||||
|
{label}
|
||||||
|
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative w-full">
|
||||||
|
{leftIcon && (
|
||||||
|
<span className="absolute left-3 top-3 text-slate-400">
|
||||||
|
{leftIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
rows={rows}
|
||||||
|
maxLength={maxLength}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
w-full rounded-lg
|
||||||
|
bg-(--background) text-(--text-primary) text-sm
|
||||||
|
border ${props.error ? 'border-red-500' : 'border-(--card-border)'}
|
||||||
|
px-3 py-3
|
||||||
|
outline-none
|
||||||
|
transition-all duration-200
|
||||||
|
hover:border-blue-500
|
||||||
|
focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20
|
||||||
|
disabled:bg-(--background-secondary) disabled:text-(--text-secondary) disabled:cursor-not-allowed disabled:border-(--card-border)
|
||||||
|
placeholder-(--text-secondary)/50
|
||||||
|
resize-y
|
||||||
|
${leftIcon ? "pl-10" : ""}
|
||||||
|
${rightIcon ? "pr-10" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rightIcon && (
|
||||||
|
<span className="absolute right-3 top-3 text-slate-400">
|
||||||
|
{rightIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomTextArea;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import CustomInput from "./CustomInput";
|
||||||
|
import CustomTextArea from "./CustomTextArea";
|
||||||
|
export { default as CustomBackButton } from "./CustomBackButton";
|
||||||
|
import CustomDropdown from "./CustomDropdown";
|
||||||
|
import CustomSearchableDropdown from "./CustomSearchableDropdown";
|
||||||
|
import CustomMultiSelect from "./CustomMultiSelect";
|
||||||
|
import CustomFileUploader from "./CustomFileUploader";
|
||||||
|
import CustomCheckBox from "./CustomCheckBox";
|
||||||
|
import CustomTable from "./CustomTable";
|
||||||
|
import CustomButton from "./CustomButton";
|
||||||
|
import CustomRadio from "./CustomRadio";
|
||||||
|
import CustomSwitch from "./CustomSwitch";
|
||||||
|
import CustomModal from "./CustomModal";
|
||||||
|
import CustomConfirmationModal from "./CustomConfirmationModal";
|
||||||
|
import CustomDatePicker from "./CustomDatePicker";
|
||||||
|
import CustomDateTimePicker from "./CustomDateTimePicker";
|
||||||
|
import CustomOTPInput from "./CustomOTPInput";
|
||||||
|
import CustomTabs from "./CustomTabs";
|
||||||
|
import CustomIncrement from "./CustomIncrement";
|
||||||
|
import CustomLoader from "./CustomLoader";
|
||||||
|
import CustomActionMenu, { CustomActionItem } from "./CustomActionMenu";
|
||||||
|
import CustomStatus from "./CustomStatus";
|
||||||
|
|
||||||
|
export {
|
||||||
|
CustomInput,
|
||||||
|
CustomTextArea,
|
||||||
|
CustomDropdown,
|
||||||
|
CustomSearchableDropdown,
|
||||||
|
CustomMultiSelect,
|
||||||
|
CustomFileUploader,
|
||||||
|
CustomCheckBox,
|
||||||
|
CustomTable,
|
||||||
|
CustomButton,
|
||||||
|
CustomRadio,
|
||||||
|
CustomSwitch,
|
||||||
|
CustomModal,
|
||||||
|
CustomConfirmationModal,
|
||||||
|
CustomDatePicker,
|
||||||
|
CustomDateTimePicker,
|
||||||
|
CustomOTPInput,
|
||||||
|
CustomTabs,
|
||||||
|
CustomIncrement,
|
||||||
|
CustomLoader,
|
||||||
|
CustomActionMenu,
|
||||||
|
CustomActionItem,
|
||||||
|
CustomStatus,
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useSidebar } from "../../context/SidebarContext";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import { authApi } from "../../application/Authentication/AuthApi";
|
||||||
|
import { clearAuthCookies } from "../../lib/authCookies";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const AppHeader: React.FC = () => {
|
||||||
|
const { t } = useTranslation(['profile', 'common']);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, } = useAuth();
|
||||||
|
const { toggleMobileSidebar } = useSidebar();
|
||||||
|
|
||||||
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
clearAuthCookies();
|
||||||
|
navigate("/signin");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!userMenuRef.current?.contains(event.target as Node)) {
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fullName = user
|
||||||
|
? `${user.first_name || ""} ${user.last_name || ""}`.trim() || t('common:common.loading')
|
||||||
|
: t('common:common.loading');
|
||||||
|
|
||||||
|
const initials =
|
||||||
|
user?.first_name && user?.last_name
|
||||||
|
? `${user.first_name[0]}${user.last_name[0]}`
|
||||||
|
: "U";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 flex w-full bg-[var(--header-bg)] border-b border-[var(--header-border)] transition-colors duration-200">
|
||||||
|
<div className="flex flex-grow items-center gap-4 px-4 py-3 lg:px-6">
|
||||||
|
{/* LEFT: Mobile Toggle & Brand (Mobile only) */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center p-2 text-[var(--header-text)] hover:bg-[var(--header-border)] rounded-lg lg:hidden transition-colors"
|
||||||
|
onClick={toggleMobileSidebar}
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
>
|
||||||
|
<Menu size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link to="/" className="lg:hidden flex items-center gap-2">
|
||||||
|
<span className="font-bold text-[var(--header-text)] text-lg">F&L</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logistics Network Pattern - Multi-layer Design */}
|
||||||
|
<div className="hidden lg:flex items-center overflow-hidden">
|
||||||
|
{/* ... existing SVG content ... */}
|
||||||
|
<svg className="w-80 h-10" viewBox="0 0 320 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{/* Background grid pattern */}
|
||||||
|
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="currentColor" strokeOpacity="0.1" strokeWidth="0.5" className="text-[var(--header-text)]" />
|
||||||
|
</pattern>
|
||||||
|
<rect width="320" height="40" fill="url(#grid)" />
|
||||||
|
|
||||||
|
{/* Multiple route paths */}
|
||||||
|
<path d="M0 30 C40 30, 60 10, 100 10 S160 30, 200 20 S260 10, 320 15" stroke="currentColor" strokeOpacity="0.2" strokeWidth="2" fill="none" className="text-[var(--header-text)]" />
|
||||||
|
<path d="M0 20 C50 15, 80 25, 120 20 S180 10, 220 15 S280 25, 320 20" stroke="currentColor" strokeOpacity="0.3" strokeWidth="1.5" fill="none" className="text-[var(--header-text)]" />
|
||||||
|
<path d="M0 10 C30 15, 70 5, 110 12 S170 20, 210 12 S270 8, 320 12" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1" strokeDasharray="4 2" fill="none" className="text-[var(--header-text)]" />
|
||||||
|
|
||||||
|
{/* Hub nodes with glow effect */}
|
||||||
|
<circle cx="40" cy="25" r="6" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.5" className="text-[var(--header-text)]" />
|
||||||
|
<circle cx="40" cy="25" r="3" fill="currentColor" className="text-[var(--primary)]" />
|
||||||
|
|
||||||
|
<circle cx="120" cy="15" r="8" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.5" strokeWidth="2" className="text-[var(--header-text)]" />
|
||||||
|
<circle cx="120" cy="15" r="4" fill="currentColor" className="text-[var(--primary)]" />
|
||||||
|
|
||||||
|
<circle cx="200" cy="18" r="6" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.5" className="text-[var(--header-text)]" />
|
||||||
|
<circle cx="200" cy="18" r="3" fill="currentColor" className="text-[var(--primary)]" />
|
||||||
|
|
||||||
|
<circle cx="280" cy="16" r="5" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.3" strokeWidth="1" className="text-[var(--header-text)]" />
|
||||||
|
<circle cx="280" cy="16" r="2.5" fill="currentColor" className="text-[var(--primary)]" />
|
||||||
|
|
||||||
|
{/* Package icons */}
|
||||||
|
<g transform="translate(70, 22)">
|
||||||
|
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-[var(--header-text)]" />
|
||||||
|
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-[var(--header-text)]" />
|
||||||
|
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-[var(--header-text)]" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(160, 8)">
|
||||||
|
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
|
||||||
|
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||||
|
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(240, 20)">
|
||||||
|
<rect width="8" height="6" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
|
||||||
|
<line x1="0" y1="2" x2="8" y2="2" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Connecting dots */}
|
||||||
|
<circle cx="20" cy="22" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||||
|
<circle cx="90" cy="12" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||||
|
<circle cx="150" cy="22" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||||
|
<circle cx="230" cy="14" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||||
|
<circle cx="300" cy="18" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Actions */}
|
||||||
|
<div className="flex items-center gap-2 sm:gap-4 ml-auto">
|
||||||
|
|
||||||
|
{/* User Menu */}
|
||||||
|
<div className="relative" ref={userMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsUserMenuOpen((prev) => !prev)}
|
||||||
|
className="
|
||||||
|
flex items-center gap-2
|
||||||
|
pl-2 pr-1 py-1.5
|
||||||
|
rounded-lg
|
||||||
|
hover:bg-(--header-border)
|
||||||
|
transition-colors
|
||||||
|
border border-transparent hover:border-(--header-border)
|
||||||
|
"
|
||||||
|
aria-expanded={isUserMenuOpen}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 text-sm font-semibold">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:block text-left mr-1">
|
||||||
|
<p className="text-sm font-medium text-(--header-text) leading-none">{fullName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-gray-400 transition-transform duration-200 ${isUserMenuOpen ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isUserMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
absolute right-0 mt-2 w-56
|
||||||
|
rounded-xl border border-(--card-border)
|
||||||
|
bg-(--card-bg)
|
||||||
|
shadow-md
|
||||||
|
overflow-hidden
|
||||||
|
animate-in fade-in zoom-in-95 duration-150
|
||||||
|
origin-top-right
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 border-b border-(--card-border) bg-(--background)">
|
||||||
|
<p className="text-xs text-(--text-secondary) truncate mt-0.5">
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-1">
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-(--text-primary) hover:bg-(--background-secondary) transition-colors"
|
||||||
|
>
|
||||||
|
{t('profile:header.profileSettings')}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
{t('profile:header.signOut')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppHeader;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { SidebarProvider, useSidebar } from "../../context/SidebarContext";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import AppHeader from "./AppHeader";
|
||||||
|
import AppSidebar from "./AppSidebar";
|
||||||
|
|
||||||
|
const LayoutContent: React.FC = () => {
|
||||||
|
const { isExpanded } = useSidebar();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--background)] transition-colors duration-200 flex flex-col">
|
||||||
|
<AppSidebar />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex-1 flex flex-col min-h-screen transition-all duration-300 ease-in-out
|
||||||
|
${isExpanded ? "lg:ltr:ml-[260px] lg:rtl:mr-[260px] lg:rtl:ml-0" : "lg:ltr:ml-[80px] lg:rtl:mr-[80px] lg:rtl:ml-0"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<main className="flex-1 p-4 md:p-6 lg:p-6 animate-fade-in w-full max-w-[1920px] mx-auto">
|
||||||
|
{/* Outlet Container - Ensures content is constrained but responsive */}
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppLayout: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<LayoutContent />
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
LayoutGrid,
|
||||||
|
Package,
|
||||||
|
UserCog,
|
||||||
|
Building2,
|
||||||
|
UsersRound,
|
||||||
|
X,
|
||||||
|
LogOut,
|
||||||
|
PanelLeft,
|
||||||
|
Palette,
|
||||||
|
Settings,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useSidebar } from "../../context/SidebarContext";
|
||||||
|
import { usePermission } from "../../lib/usePermission";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import { authApi } from "../../application/Authentication/AuthApi";
|
||||||
|
import { clearAuthCookies } from "../../lib/authCookies";
|
||||||
|
|
||||||
|
|
||||||
|
interface SubMenuItem {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
access?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
access?: string;
|
||||||
|
submenu?: SubMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
icon: <LayoutGrid size={22} />,
|
||||||
|
name: "Dashboard",
|
||||||
|
path: "/dashboard",
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
icon: <Building2 size={22} />,
|
||||||
|
name: "Tenants",
|
||||||
|
path: "/tenants",
|
||||||
|
access: "superadmin.tenant.read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <UsersRound size={22} />,
|
||||||
|
name: "Users",
|
||||||
|
path: "/users",
|
||||||
|
access: "admin.user.read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <UserCog size={22} />,
|
||||||
|
name: "Roles",
|
||||||
|
path: "/roles",
|
||||||
|
access: "admin.role.read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Palette size={22} />,
|
||||||
|
name: "Themes",
|
||||||
|
path: "/themes",
|
||||||
|
access: "superadmin.palette.read",
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
icon: <Settings size={22} />,
|
||||||
|
name: "Settings",
|
||||||
|
path: "/settings",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const AppSidebar: React.FC = () => {
|
||||||
|
const { t } = useTranslation('common');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
isExpanded,
|
||||||
|
isMobileOpen,
|
||||||
|
isMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
closeMobileSidebar,
|
||||||
|
} = useSidebar();
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const { can } = usePermission();
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
const getNavTranslation = (name: string): string => {
|
||||||
|
const keyMap: Record<string, string> = {
|
||||||
|
'Dashboard': 'dashboard',
|
||||||
|
'Orders': 'orders',
|
||||||
|
'Tenants': 'tenants',
|
||||||
|
'Users': 'users',
|
||||||
|
'Roles': 'roles',
|
||||||
|
'Themes': 'themes',
|
||||||
|
'Configuration': 'configuration',
|
||||||
|
'Locations': 'locations',
|
||||||
|
'Packaging': 'packaging',
|
||||||
|
'Dropdowns': 'dropdowns',
|
||||||
|
'Settings': 'settings',
|
||||||
|
};
|
||||||
|
|
||||||
|
const key = keyMap[name];
|
||||||
|
if (!key) {
|
||||||
|
console.warn(`Missing translation key for: ${name}`);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t(`nav.${key}`, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const isActive = useCallback(
|
||||||
|
(path: string) => location.pathname === path || location.pathname.startsWith(path + "/"),
|
||||||
|
[location.pathname]
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleNavItems = useMemo(
|
||||||
|
() => navItems.filter((item) => {
|
||||||
|
if (!item.access) return true;
|
||||||
|
|
||||||
|
if (item.submenu && item.submenu.length > 0) {
|
||||||
|
return item.submenu.some(subItem => !subItem.access || can(subItem.access));
|
||||||
|
}
|
||||||
|
|
||||||
|
return can(item.access);
|
||||||
|
}),
|
||||||
|
[can]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [openSubmenus, setOpenSubmenus] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
|
||||||
|
setOpenSubmenus(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
|
||||||
|
Object.keys(newState).forEach(menuName => {
|
||||||
|
if (newState[menuName]) {
|
||||||
|
const navItem = navItems.find(item => item.name === menuName);
|
||||||
|
|
||||||
|
if (navItem && navItem.submenu) {
|
||||||
|
const isMatchingSubmenu = navItem.submenu.some(subItem =>
|
||||||
|
currentPath === subItem.path || currentPath.startsWith(subItem.path + "/")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isMatchingSubmenu) {
|
||||||
|
newState[menuName] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const toggleSubmenu = (name: string) => {
|
||||||
|
if (!isExpanded && !isMobile) {
|
||||||
|
toggleSidebar(); // Auto expand when clicking submenu in collapsed state
|
||||||
|
}
|
||||||
|
setOpenSubmenus(prev => ({ ...prev, [name]: !prev[name] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
clearAuthCookies();
|
||||||
|
navigate("/signin");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar width logic
|
||||||
|
const sidebarWidth = isExpanded ? "w-[260px]" : "w-[80px]";
|
||||||
|
const mobileClasses = isMobileOpen ? "translate-x-0" : "-translate-x-full";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile Backdrop */}
|
||||||
|
{isMobileOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden backdrop-blur-sm transition-opacity duration-300 animate-fade-in"
|
||||||
|
onClick={closeMobileSidebar}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
fixed top-0 left-0 z-50 h-screen bg-[var(--sidebar-bg)] text-[var(--sidebar-text)] flex flex-col
|
||||||
|
transition-all duration-300 ease-in-out border-r border-[var(--sidebar-border)]
|
||||||
|
lg:translate-x-0 overflow-x-hidden
|
||||||
|
${isMobile ? "w-[280px]" : sidebarWidth}
|
||||||
|
${isMobile ? mobileClasses : ""}
|
||||||
|
sidebar
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`h-[70px] flex items-center px-4 border-b border-(--sidebar-border)/50 ${!isExpanded && !isMobile ? 'justify-center' : 'justify-between'}`}>
|
||||||
|
{/* Logo - Only show when expanded or mobile */}
|
||||||
|
{(isExpanded || isMobile) && (
|
||||||
|
<Link to="/dashboard" className="flex items-center gap-3 overflow-hidden ml-1" onClick={isMobile ? closeMobileSidebar : undefined}>
|
||||||
|
{user?.role?.accesses?.includes("superadmin.tenant.read") ? (
|
||||||
|
<img
|
||||||
|
src="https://maskantech.netlify.app/maskan_footer1.png"
|
||||||
|
alt="Maskan Tech"
|
||||||
|
className="h-8 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
) : user?.tenant_logo_url ? (
|
||||||
|
<img
|
||||||
|
src={user.tenant_logo_url}
|
||||||
|
alt={user.tenant_name || "Logo"}
|
||||||
|
className="h-8 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="shrink-0 text-blue-400">
|
||||||
|
<Package size={32} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-(--sidebar-active-text) whitespace-nowrap">
|
||||||
|
F&L
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop Toggle Button */}
|
||||||
|
{!isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className={`p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/10 transition-colors ${!isExpanded ? "" : ""}`}
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
>
|
||||||
|
<PanelLeft size={20} className={!isExpanded ? "rotate-180" : ""} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Close Button */}
|
||||||
|
{isMobile && (
|
||||||
|
<button onClick={closeMobileSidebar} className="p-1 rounded-md text-slate-400 hover:text-white hover:bg-white/10">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-6 px-3 custom-scrollbar">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{visibleNavItems.map((nav) => {
|
||||||
|
const active = isActive(nav.path);
|
||||||
|
const hasSubmenu = nav.submenu && nav.submenu.length > 0;
|
||||||
|
const isMenuOpen = openSubmenus[nav.name];
|
||||||
|
|
||||||
|
if (hasSubmenu) {
|
||||||
|
return (
|
||||||
|
<li key={nav.name}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSubmenu(nav.name)}
|
||||||
|
className={`
|
||||||
|
w-full relative flex items-center gap-3 px-3 py-3 rounded-lg transition-all duration-200 group
|
||||||
|
${isMenuOpen
|
||||||
|
? "bg-white/5 text-(--sidebar-active-text)"
|
||||||
|
: "text-(--sidebar-text) hover:bg-(--sidebar-hover-bg)"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{nav.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isExpanded || isMobile) && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-left whitespace-nowrap font-medium">
|
||||||
|
{getNavTranslation(nav.name)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-shrink-0 transition-transform duration-200">
|
||||||
|
{isMenuOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isExpanded && !isMobile && (
|
||||||
|
<div className="absolute left-full ml-4 px-2 py-1 bg-slate-800 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50 whitespace-nowrap pointer-events-none">
|
||||||
|
{getNavTranslation(nav.name)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Submenu Items */}
|
||||||
|
<div className={`overflow-hidden transition-all duration-300 ${isMenuOpen && (isExpanded || isMobile) ? "max-h-96 opacity-100 mt-1" : "max-h-0 opacity-0"}`}>
|
||||||
|
<ul className="space-y-1 pl-9">
|
||||||
|
{nav.submenu?.filter(subItem => !subItem.access || can(subItem.access)).map((subItem) => {
|
||||||
|
const subActive = isActive(subItem.path || "");
|
||||||
|
return (
|
||||||
|
<li key={subItem.name}>
|
||||||
|
<Link
|
||||||
|
to={subItem.path || "#"}
|
||||||
|
onClick={isMobile ? closeMobileSidebar : undefined}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors block
|
||||||
|
${subActive
|
||||||
|
? "bg-(--sidebar-active-bg) text-(--sidebar-active-text) font-medium"
|
||||||
|
: "text-(--sidebar-text-muted) hover:text-(--sidebar-text) hover:bg-(--sidebar-hover-bg)"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{subItem.icon && (
|
||||||
|
<div className="flex-shrink-0 scale-90">
|
||||||
|
{subItem.icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{getNavTranslation(subItem.name)}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={nav.name}>
|
||||||
|
<Link
|
||||||
|
to={nav.path}
|
||||||
|
onClick={isMobile ? closeMobileSidebar : undefined}
|
||||||
|
className={`
|
||||||
|
relative flex items-center gap-3 px-3 py-3 rounded-lg transition-all duration-200 group
|
||||||
|
${active
|
||||||
|
? "bg-(--sidebar-active-bg) text-(--sidebar-active-text)"
|
||||||
|
: "text-(--sidebar-text) hover:bg-(--sidebar-hover-bg)"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{nav.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
whitespace-nowrap font-medium transition-all duration-300
|
||||||
|
${(isExpanded || isMobile) ? "opacity-100 w-auto" : "opacity-0 w-0 hidden"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{getNavTranslation(nav.name)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Tooltip for collapsed mode */}
|
||||||
|
{!isExpanded && !isMobile && (
|
||||||
|
<div className="absolute left-full ml-4 px-2 py-1 bg-slate-800 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50 whitespace-nowrap pointer-events-none">
|
||||||
|
{getNavTranslation(nav.name)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-slate-700/50">
|
||||||
|
{/* Logout */}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={`flex items-center gap-3 w-full px-3 py-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors overflow-hidden ${(isExpanded || isMobile) ? "justify-start" : "justify-center"}`}
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut size={20} />
|
||||||
|
{(isExpanded || isMobile) && (
|
||||||
|
<span className="font-medium">{t('actions.signout')}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppSidebar;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative p-6 bg-slate-900 z-1 sm:p-0">
|
||||||
|
<div className="relative flex flex-col justify-center w-full h-screen lg:flex-row sm:p-0">
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
className="items-center hidden w-full h-full lg:w-1/2 lg:grid relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url('/AuthLayout.jpg')",
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 " />
|
||||||
|
<div className="relative flex items-center justify-center z-10">
|
||||||
|
{/* <div className="flex flex-col items-center max-w-xs">
|
||||||
|
<Link to="/" className="block mb-4">
|
||||||
|
<img
|
||||||
|
width={231}
|
||||||
|
height={48}
|
||||||
|
src="/images/logo/auth-logo.svg"
|
||||||
|
alt="Logo"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<p className="text-center text-gray-200 mt-2 text-sm font-medium">
|
||||||
|
Streamline your global fulfillment and logistics operations.
|
||||||
|
</p>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useSidebar } from "../../context/SidebarContext";
|
||||||
|
|
||||||
|
const Backdrop: React.FC = () => {
|
||||||
|
const { isMobileOpen, toggleMobileSidebar } = useSidebar();
|
||||||
|
|
||||||
|
if (!isMobileOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
|
||||||
|
onClick={toggleMobileSidebar}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Backdrop;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Use VITE_API_BASE_URL to match your backend host when working with cookies.
|
||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { authApi } from "../application/Authentication/AuthApi";
|
||||||
|
import type {
|
||||||
|
AuthUser,
|
||||||
|
SigninRequest,
|
||||||
|
} from "../application/Authentication/AuthTypes";
|
||||||
|
import {
|
||||||
|
setAuthCookies,
|
||||||
|
getAccessToken,
|
||||||
|
clearAuthCookies,
|
||||||
|
} from "../lib/authCookies";
|
||||||
|
import i18n from "../i18n/config";
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: AuthUser | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (credentials: SigninRequest, rememberMe?: boolean) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
hasAccess: (accessCode: string) => boolean;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
updateLanguage: (language: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Bootstrap auth on app load
|
||||||
|
useEffect(() => {
|
||||||
|
const bootstrapAuth = async () => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userData = await authApi.me();
|
||||||
|
setUser(userData);
|
||||||
|
|
||||||
|
if (userData.preferred_language) {
|
||||||
|
i18n.changeLanguage(userData.preferred_language);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearAuthCookies();
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bootstrapAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (credentials: SigninRequest, rememberMe = false) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await authApi.signin(credentials);
|
||||||
|
setAuthCookies(response, rememberMe);
|
||||||
|
setUser(response.user);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
clearAuthCookies();
|
||||||
|
setUser(null);
|
||||||
|
window.location.href = "/signin";
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const hasAccess = (accessCode: string): boolean => {
|
||||||
|
if (!user?.role?.accesses) return false;
|
||||||
|
return user.role.accesses.includes(accessCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshUser = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await authApi.me();
|
||||||
|
setUser(userData);
|
||||||
|
|
||||||
|
if (userData.preferred_language) {
|
||||||
|
i18n.changeLanguage(userData.preferred_language);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLanguage = async (language: string) => {
|
||||||
|
if (!user) throw new Error('No user logged in');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedUser = await authApi.updateLanguage(user.id, language);
|
||||||
|
setUser(updatedUser);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update language preference:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
hasAccess,
|
||||||
|
refreshUser,
|
||||||
|
updateLanguage,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
type SidebarContextType = {
|
||||||
|
isExpanded: boolean;
|
||||||
|
isMobileOpen: boolean;
|
||||||
|
isHovered: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
toggleMobileSidebar: () => void;
|
||||||
|
closeMobileSidebar: () => void;
|
||||||
|
setIsHovered: (isHovered: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useSidebar = () => {
|
||||||
|
const context = useContext(SidebarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
// Desktop state: defaulting to expanded
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
// Mobile state: defaulting to closed
|
||||||
|
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||||
|
// Hover state for collapsed desktop sidebar
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
// Screen size state
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Handle Resize
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
const mobile = window.innerWidth < 1024; // lg breakpoint
|
||||||
|
setIsMobile(mobile);
|
||||||
|
if (!mobile) {
|
||||||
|
setIsMobileOpen(false); // Close mobile drawer when switching to desktop
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close mobile sidebar on route change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setIsMobileOpen(false);
|
||||||
|
}
|
||||||
|
}, [location.pathname, isMobile]);
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setIsExpanded((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMobileSidebar = () => {
|
||||||
|
setIsMobileOpen((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMobileSidebar = () => {
|
||||||
|
setIsMobileOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider
|
||||||
|
value={{
|
||||||
|
isExpanded,
|
||||||
|
isMobileOpen,
|
||||||
|
isHovered,
|
||||||
|
isMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
toggleMobileSidebar,
|
||||||
|
closeMobileSidebar,
|
||||||
|
setIsHovered,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "./AuthContext";
|
||||||
|
import type { ColorPalette } from "../application/theme/ThemeTypes";
|
||||||
|
import { paletteApi } from "../application/theme/PaletteApi";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
currentPalette: ColorPalette | null;
|
||||||
|
refreshTheme: () => Promise<void>;
|
||||||
|
setTheme: (palette: ColorPalette) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [currentPalette, setCurrentPalette] = useState<ColorPalette | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Function to apply colors to CSS variables
|
||||||
|
const applyTheme = (palette: ColorPalette) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty("--background", palette.colors.background);
|
||||||
|
root.style.setProperty("--background-secondary", palette.colors.background_secondary);
|
||||||
|
|
||||||
|
root.style.setProperty("--text-primary", palette.colors.text_primary);
|
||||||
|
root.style.setProperty("--text-secondary", palette.colors.text_secondary);
|
||||||
|
|
||||||
|
root.style.setProperty("--card-bg", palette.colors.card_bg);
|
||||||
|
root.style.setProperty("--card-border", palette.colors.card_border);
|
||||||
|
|
||||||
|
root.style.setProperty("--header-bg", palette.colors.header_bg);
|
||||||
|
root.style.setProperty("--header-text", palette.colors.header_text);
|
||||||
|
root.style.setProperty("--header-border", palette.colors.header_border);
|
||||||
|
|
||||||
|
root.style.setProperty("--sidebar-bg", palette.colors.sidebar_bg);
|
||||||
|
root.style.setProperty("--sidebar-text", palette.colors.sidebar_text);
|
||||||
|
root.style.setProperty("--sidebar-active-bg", palette.colors.sidebar_active_bg);
|
||||||
|
root.style.setProperty("--sidebar-active-text", palette.colors.sidebar_active_text);
|
||||||
|
root.style.setProperty("--sidebar-hover-bg", palette.colors.sidebar_hover_bg);
|
||||||
|
root.style.setProperty("--sidebar-border", palette.colors.sidebar_border);
|
||||||
|
|
||||||
|
root.style.setProperty("--primary", palette.colors.primary);
|
||||||
|
root.style.setProperty("--primary-hover", palette.colors.primary_hover);
|
||||||
|
|
||||||
|
root.style.setProperty("--table-header-bg", palette.colors.table_header_bg);
|
||||||
|
root.style.setProperty("--table-row-hover", palette.colors.table_row_hover);
|
||||||
|
root.style.setProperty("--table-border", palette.colors.table_border);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = async (palette: ColorPalette) => {
|
||||||
|
setCurrentPalette(palette);
|
||||||
|
applyTheme(palette);
|
||||||
|
localStorage.setItem("user_theme_preference", palette.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshTheme = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (!user) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const palettes = await paletteApi.getAllPalettes();
|
||||||
|
|
||||||
|
let targetPalette: ColorPalette | undefined;
|
||||||
|
|
||||||
|
// 1. Check local storage preference
|
||||||
|
const savedPaletteId = localStorage.getItem("user_theme_preference");
|
||||||
|
if (savedPaletteId) {
|
||||||
|
targetPalette = palettes.find((p) => p.id === savedPaletteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If no preference/not found, use default
|
||||||
|
if (!targetPalette) {
|
||||||
|
targetPalette = palettes.find((p) => p.is_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to first available
|
||||||
|
if (!targetPalette && palettes.length > 0) {
|
||||||
|
targetPalette = palettes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetPalette) {
|
||||||
|
setCurrentPalette(targetPalette);
|
||||||
|
applyTheme(targetPalette);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load theme:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshTheme();
|
||||||
|
}, [user]); // Re-run when user logs in/out
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ currentPalette, refreshTheme, setTheme, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import i18next from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
|
import enCommon from './locales/en/common.json';
|
||||||
|
import arCommon from './locales/ar/common.json';
|
||||||
|
import enOrders from './locales/en/orders.json';
|
||||||
|
import arOrders from './locales/ar/orders.json';
|
||||||
|
import enUsers from './locales/en/users.json';
|
||||||
|
import arUsers from './locales/ar/users.json';
|
||||||
|
import enRoles from './locales/en/roles.json';
|
||||||
|
import arRoles from './locales/ar/roles.json';
|
||||||
|
import enPackaging from './locales/en/packaging.json';
|
||||||
|
import arPackaging from './locales/ar/packaging.json';
|
||||||
|
import enLocations from './locales/en/locations.json';
|
||||||
|
import arLocations from './locales/ar/locations.json';
|
||||||
|
import enProfile from './locales/en/profile.json';
|
||||||
|
import arProfile from './locales/ar/profile.json';
|
||||||
|
import enDashboard from './locales/en/dashboard.json';
|
||||||
|
import arDashboard from './locales/ar/dashboard.json';
|
||||||
|
import enDropdowns from './locales/en/dropdowns.json';
|
||||||
|
import arDropdowns from './locales/ar/dropdowns.json';
|
||||||
|
|
||||||
|
export const languages = {
|
||||||
|
en: { name: 'English', dir: 'ltr' },
|
||||||
|
ar: { name: 'العربية', dir: 'rtl' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SupportedLanguage = keyof typeof languages;
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
en: {
|
||||||
|
common: enCommon,
|
||||||
|
orders: enOrders,
|
||||||
|
users: enUsers,
|
||||||
|
roles: enRoles,
|
||||||
|
packaging: enPackaging,
|
||||||
|
locations: enLocations,
|
||||||
|
profile: enProfile,
|
||||||
|
dashboard: enDashboard,
|
||||||
|
dropdowns: enDropdowns,
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
common: arCommon,
|
||||||
|
orders: arOrders,
|
||||||
|
users: arUsers,
|
||||||
|
roles: arRoles,
|
||||||
|
packaging: arPackaging,
|
||||||
|
locations: arLocations,
|
||||||
|
profile: arProfile,
|
||||||
|
dashboard: arDashboard,
|
||||||
|
dropdowns: arDropdowns,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
i18next
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources,
|
||||||
|
defaultNS: 'common',
|
||||||
|
fallbackLng: 'en',
|
||||||
|
supportedLngs: Object.keys(languages),
|
||||||
|
|
||||||
|
detection: {
|
||||||
|
order: ['localStorage', 'navigator'],
|
||||||
|
caches: ['localStorage'],
|
||||||
|
lookupLocalStorage: 'preferred_language',
|
||||||
|
},
|
||||||
|
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
react: {
|
||||||
|
useSuspense: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateDirection = (lng: string) => {
|
||||||
|
const dir = languages[lng as SupportedLanguage]?.dir || 'ltr';
|
||||||
|
document.documentElement.setAttribute('dir', dir);
|
||||||
|
document.documentElement.setAttribute('lang', lng);
|
||||||
|
};
|
||||||
|
|
||||||
|
i18next.on('languageChanged', updateDirection);
|
||||||
|
|
||||||
|
if (i18next.language) {
|
||||||
|
updateDirection(i18next.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18next;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "لوحة القيادة",
|
||||||
|
"orders": "الطلبات",
|
||||||
|
"locations": "المواقع",
|
||||||
|
"packaging": "التعبئة والتغليف",
|
||||||
|
"tenants": "المستأجرون",
|
||||||
|
"users": "المستخدمون",
|
||||||
|
"roles": "الأدوار",
|
||||||
|
"themes": "المظاهر",
|
||||||
|
"profile": "الملف الشخصي",
|
||||||
|
"settings": "الإعدادات",
|
||||||
|
"configuration": "التكوين",
|
||||||
|
"dropdowns": "القوائم المنسدلة"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "حفظ",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"edit": "تعديل",
|
||||||
|
"delete": "حذف",
|
||||||
|
"add": "إضافة",
|
||||||
|
"create": "إنشاء",
|
||||||
|
"update": "تحديث",
|
||||||
|
"search": "بحث",
|
||||||
|
"filter": "تصفية",
|
||||||
|
"export": "تصدير",
|
||||||
|
"import": "استيراد",
|
||||||
|
"reset": "إعادة تعيين",
|
||||||
|
"submit": "إرسال",
|
||||||
|
"close": "إغلاق",
|
||||||
|
"confirm": "تأكيد",
|
||||||
|
"back": "رجوع",
|
||||||
|
"next": "التالي",
|
||||||
|
"previous": "السابق",
|
||||||
|
"view": "عرض",
|
||||||
|
"refresh": "تحديث",
|
||||||
|
"signout": "تسجيل الخروج",
|
||||||
|
"logout": "تسجيل الخروج"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"yes": "نعم",
|
||||||
|
"no": "لا",
|
||||||
|
"loading": "جاري التحميل...",
|
||||||
|
"error": "خطأ",
|
||||||
|
"success": "نجاح",
|
||||||
|
"warning": "تحذير",
|
||||||
|
"info": "معلومات",
|
||||||
|
"noData": "لا توجد بيانات متاحة",
|
||||||
|
"selectAll": "تحديد الكل",
|
||||||
|
"selected": "محدد",
|
||||||
|
"total": "المجموع",
|
||||||
|
"showing": "عرض",
|
||||||
|
"of": "من",
|
||||||
|
"entries": "إدخالات",
|
||||||
|
"page": "صفحة",
|
||||||
|
"rowsPerPage": "صفوف لكل صفحة",
|
||||||
|
"qty": "الكمية",
|
||||||
|
"volumetricWeight": "الوزن الحجمي"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"name": "الاسم",
|
||||||
|
"roleName": "اسم الدور",
|
||||||
|
"tenant": "المستأجر",
|
||||||
|
"created": "تم الإنشاء",
|
||||||
|
"updated": "تم التحديث",
|
||||||
|
"actions": "إجراءات",
|
||||||
|
"email": "البريد الإلكتروني",
|
||||||
|
"phone": "الهاتف",
|
||||||
|
"role": "الدور",
|
||||||
|
"status": "الحالة"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "نشط",
|
||||||
|
"inactive": "غير نشط",
|
||||||
|
"pending": "قيد الانتظار",
|
||||||
|
"approved": "موافق عليه",
|
||||||
|
"rejected": "مرفوض",
|
||||||
|
"new": "جديد",
|
||||||
|
"inProgress": "قيد التنفيذ",
|
||||||
|
"completed": "مكتمل",
|
||||||
|
"cancelled": "ملغى"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "الإعدادات",
|
||||||
|
"language": {
|
||||||
|
"title": "تفضيل اللغة",
|
||||||
|
"description": "اختر لغتك المفضلة لواجهة التطبيق",
|
||||||
|
"label": "اللغة",
|
||||||
|
"selectLabel": "اختار اللغة",
|
||||||
|
"updated": "تم تحديث تفضيل اللغة بنجاح",
|
||||||
|
"confirmTitle": "تغيير اللغة؟",
|
||||||
|
"confirmMessage": "سيتم إعادة تحميل الصفحة لتطبيق إعدادات اللغة الجديدة. هل تريد المتابعة؟"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "هذا الحقل مطلوب",
|
||||||
|
"email": "يرجى إدخال عنوان بريد إلكتروني صالح",
|
||||||
|
"minLength": "يجب أن يكون {{min}} أحرف على الأقل",
|
||||||
|
"maxLength": "يجب ألا يزيد عن {{max}} أحرف",
|
||||||
|
"passwordMatch": "كلمات المرور غير متطابقة"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saveSuccess": "تم الحفظ بنجاح",
|
||||||
|
"updateSuccess": "تم التحديث بنجاح",
|
||||||
|
"deleteSuccess": "تم الحذف بنجاح",
|
||||||
|
"createSuccess": "تم الإنشاء بنجاح",
|
||||||
|
"error": "حدث خطأ. يرجى المحاولة مرة أخرى.",
|
||||||
|
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا العنصر؟"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "لوحة المعلومات قيد التطوير",
|
||||||
|
"message": "نحن نعمل على إنشاء تجربة لوحة معلومات قوية لمنحك رؤى أفضل. يرجى التحقق مرة أخرى قريبا!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"title": "إعدادات القوائم المنسدلة",
|
||||||
|
"noPermission": "ليس لديك صلاحية لعرض أي إعدادات قوائم منسدلة.",
|
||||||
|
"tabs": {
|
||||||
|
"locationTypes": "أنواع المواقع",
|
||||||
|
"packageTypes": "أنواع الطرود",
|
||||||
|
"packageMaterials": "مواد التغليف",
|
||||||
|
"productCategories": "فئات المنتجات"
|
||||||
|
},
|
||||||
|
"locationTypes": {
|
||||||
|
"title": "أنواع المواقع",
|
||||||
|
"addButton": "إضافة نوع موقع",
|
||||||
|
"editTitle": "تعديل نوع الموقع",
|
||||||
|
"addTitle": "إضافة نوع موقع",
|
||||||
|
"deleteTitle": "حذف نوع الموقع؟",
|
||||||
|
"deleteMessage": "هل أنت متأكد أنك تريد حذف نوع الموقع هذا؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"name": "الاسم",
|
||||||
|
"namePlaceholder": "أدخل اسم نوع الموقع"
|
||||||
|
},
|
||||||
|
"packageTypes": {
|
||||||
|
"title": "أنواع الطرود",
|
||||||
|
"addButton": "إضافة نوع طرد",
|
||||||
|
"editTitle": "تعديل نوع الطرد",
|
||||||
|
"addTitle": "إضافة نوع طرد",
|
||||||
|
"deleteTitle": "حذف نوع الطرد؟",
|
||||||
|
"deleteMessage": "هل أنت متأكد أنك تريد حذف نوع الطرد هذا؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"name": "الاسم",
|
||||||
|
"namePlaceholder": "أدخل اسم نوع الطرد"
|
||||||
|
},
|
||||||
|
"packageMaterials": {
|
||||||
|
"title": "مواد التغليف",
|
||||||
|
"addButton": "إضافة مادة تغليف",
|
||||||
|
"editTitle": "تعديل مادة التغليف",
|
||||||
|
"addTitle": "إضافة مادة تغليف",
|
||||||
|
"deleteTitle": "حذف مادة التغليف؟",
|
||||||
|
"deleteMessage": "هل أنت متأكد أنك تريد حذف مادة التغليف هذه؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"name": "الاسم",
|
||||||
|
"namePlaceholder": "أدخل اسم مادة التغليف"
|
||||||
|
},
|
||||||
|
"productCategories": {
|
||||||
|
"title": "فئات المنتجات",
|
||||||
|
"addButton": "إضافة فئة منتج",
|
||||||
|
"editTitle": "تعديل فئة المنتج",
|
||||||
|
"addTitle": "إضافة فئة منتج",
|
||||||
|
"deleteTitle": "حذف فئة المنتج؟",
|
||||||
|
"deleteMessage": "هل أنت متأكد أنك تريد حذف فئة المنتج هذه؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"name": "الاسم",
|
||||||
|
"namePlaceholder": "أدخل اسم فئة المنتج"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"title": "المواقع",
|
||||||
|
"subTitle": "إدارة مواقع المستودعات والمراكز الخاصة بك.",
|
||||||
|
"headers": {
|
||||||
|
"addLocation": "إضافة موقع",
|
||||||
|
"addNewLocation": "إضافة موقع جديد",
|
||||||
|
"editLocation": "تعديل الموقع",
|
||||||
|
"noLocationsFound": "لم يتم العثور على مواقع",
|
||||||
|
"viewMap": "عرض الخريطة",
|
||||||
|
"locationOnMap": "الموقع على الخريطة"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"noLocationsDescription": "أضف مواقع المستودعات أو المراكز الخاصة بك لإدارة الاستلام بكفاءة.",
|
||||||
|
"deleteConfirmation": "هل أنت متأكد أنك تريد حذف هذا الموقع؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"deleteTitle": "حذف الموقع؟",
|
||||||
|
"saveSuccess": "تم حفظ الموقع بنجاح",
|
||||||
|
"fetchError": "فشل جلب المواقع",
|
||||||
|
"fetchDetailsError": "فشل جلب تفاصيل الموقع",
|
||||||
|
"loading": "جارٍ التحميل..."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "اسم الموقع",
|
||||||
|
"namePlaceholder": "مثال: المستودع الرئيسي، الوحدة المركزية 1",
|
||||||
|
"type": "نوع الموقع",
|
||||||
|
"locationType": "نوع الموقع",
|
||||||
|
"selectType": "اختر النوع",
|
||||||
|
"active": "نشط",
|
||||||
|
"usedForRouting": "يستخدم للتوجيه",
|
||||||
|
"warehouse": "مستودع",
|
||||||
|
"hub": "مركز",
|
||||||
|
"factory": "مصنع",
|
||||||
|
"capabilities": "القدرات",
|
||||||
|
"capabilitiesPlaceholder": "اختر القدرات...",
|
||||||
|
"country": "الدولة",
|
||||||
|
"zipCode": "الرمز البريدي",
|
||||||
|
"state": "المنطقة / المحافظة",
|
||||||
|
"city": "المدينة",
|
||||||
|
"addressLine1": "العنوان السطر 1",
|
||||||
|
"addressLine1Placeholder": "اسم الشارع، المبنى، إلخ.",
|
||||||
|
"addressLine2": "العنوان السطر 2 (اختياري)",
|
||||||
|
"addressLine2Placeholder": "شقة، جناح، وحدة، إلخ.",
|
||||||
|
"latitude": "خط العرض",
|
||||||
|
"longitude": "خط الطول",
|
||||||
|
"selectState": "ختر المنطقة/المحافظة",
|
||||||
|
"selectCity": "اختر المدينة",
|
||||||
|
"autoFilled": "تعبئة تلقائية",
|
||||||
|
"enterState": "أدخل المنطقة/المحافظة",
|
||||||
|
"enterCity": "أدخل المدينة"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "تعديل",
|
||||||
|
"delete": "حذف",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"save": "حفظ الموقع",
|
||||||
|
"saving": "جارٍ الحفظ...",
|
||||||
|
"viewMap": "عرض الخريطة"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "الاسم مطلوب",
|
||||||
|
"typeRequired": "النوع مطلوب",
|
||||||
|
"addressRequired": "العنوان مطلوب",
|
||||||
|
"zipCodeRequired": "الرمز البريدي مطلوب",
|
||||||
|
"cityRequired": "المدينة مطلوبة",
|
||||||
|
"stateRequired": "المنطقة/المحافظة مطلوبة",
|
||||||
|
"countryRequired": "الدولة مطلوبة"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"coldStorage": "تخزين بارد",
|
||||||
|
"hazardousMaterials": "مواد خطرة",
|
||||||
|
"access247": "وصول 24/7",
|
||||||
|
"loadingDock": "رصيف تحميل",
|
||||||
|
"forkliftAvailable": "رافعة شوكية متاحة",
|
||||||
|
"cctvSurveillance": "مراقبة بالكاميرات",
|
||||||
|
"climateControlled": "تحكم بالمناخ",
|
||||||
|
"bondedWarehouse": "مستودع جمركي",
|
||||||
|
"crossDocking": "توزيع مباشر (Cross Docking)",
|
||||||
|
"orderFulfillment": "تلبية الطلبات",
|
||||||
|
"inventoryManagement": "إدارة المخزون",
|
||||||
|
"lastMileDelivery": "توصيل الميل الأخير",
|
||||||
|
"returnsProcessing": "معالجة المرتجعات",
|
||||||
|
"realTimeTracking": "تتبع في الوقت الفعلي",
|
||||||
|
"fdaApproved": "معتمد من FDA",
|
||||||
|
"customsClearance": "التخليص الجمركي"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
{
|
||||||
|
"tabs": {
|
||||||
|
"all": "الكل",
|
||||||
|
"new": "الطلبات",
|
||||||
|
"picked": "تم التقاط",
|
||||||
|
"packed": "تم التعبئة",
|
||||||
|
"ready": "جاهز للشحن",
|
||||||
|
"pickups": "الاستلام والبيانات",
|
||||||
|
"transit": "قيد النقل",
|
||||||
|
"delivered": "تم التسليم"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"order": "الطلب",
|
||||||
|
"manifestId": "رقم البيان",
|
||||||
|
"expectedDelivery": "التسليم المتوقع",
|
||||||
|
"customer": "العميل",
|
||||||
|
"product": "المنتج",
|
||||||
|
"payment": "الدفع",
|
||||||
|
"pickupAddress": "عنوان الاستلام",
|
||||||
|
"courier": "شركة الشحن",
|
||||||
|
"status": "الحالة",
|
||||||
|
"action": "إجراء",
|
||||||
|
"orders": "الطلبات",
|
||||||
|
"totalValue": "القيمة الإجمالية",
|
||||||
|
"pickup": "الاستلام",
|
||||||
|
"deliveryMethod": "طريقة التسليم"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"addOrder": "إضافة طلب",
|
||||||
|
"updateOrder": "تحديث الطلب",
|
||||||
|
"cancelOrder": "إلغاء",
|
||||||
|
"createOrder": "إنشاء طلب",
|
||||||
|
"bulkSchedule": "جدولة الاستلام",
|
||||||
|
"bulkShip": "شحن الآن",
|
||||||
|
"schedulePickup": "جدولة الاستلام",
|
||||||
|
"shipNow": "شحن الآن",
|
||||||
|
"markDelivered": "وضع علامة تم التسليم",
|
||||||
|
"viewDetails": "عرض التفاصيل",
|
||||||
|
"viewProof": "عرض الإثبات",
|
||||||
|
"edit": "تعديل",
|
||||||
|
"delete": "حذف",
|
||||||
|
"scheduleShipment": "جدولة الشحنة",
|
||||||
|
"markInTransit": "وضع علامة في النقل",
|
||||||
|
"backToOrders": "العودة للطلبات",
|
||||||
|
"markPacked": "وضع علامة تم التعبئة",
|
||||||
|
"markReady": "وضع علامة جاهز",
|
||||||
|
"readyToPick": "جاهز للالتقاط",
|
||||||
|
"readyToPack": "جاهز للتعبئة",
|
||||||
|
"readyToShip": "جاهز للشحن",
|
||||||
|
"label": "ملصق",
|
||||||
|
"markLabelled": "وضع علامة تم التسمية"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"new": "جديد",
|
||||||
|
"ready": "جاهز",
|
||||||
|
"readyToShip": "جاهز للشحن",
|
||||||
|
"readyForTransit": "جاهز للنقل",
|
||||||
|
"inTransit": "قيد النقل",
|
||||||
|
"shipped": "تم الشحن",
|
||||||
|
"delivered": "تم التسليم",
|
||||||
|
"pickupScheduled": "تم جدولة الاستلام"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"orderId": "رقم الطلب",
|
||||||
|
"orderTime": "وقت الطلب",
|
||||||
|
"customerName": "اسم العميل",
|
||||||
|
"customerPhone": "هاتف العميل",
|
||||||
|
"quantity": "الكمية",
|
||||||
|
"volumetricWeight": "الوزن الحجمي",
|
||||||
|
"cod": "الدفع عند الاستلام",
|
||||||
|
"prepaid": "مدفوع مسبقاً",
|
||||||
|
"trackingNumber": "رقم التتبع",
|
||||||
|
"carrierName": "اسم الناقل",
|
||||||
|
"moreItems": "+ {{count}} عناصر أخرى",
|
||||||
|
"receivedBy": "استلم من قبل",
|
||||||
|
"pickupAddress": "عنوان الاستلام",
|
||||||
|
"selectLocation": "اختر من المواقع المحفوظة",
|
||||||
|
"chooseLocation": "اختر موقعًا...",
|
||||||
|
"deliveryDetails": "تفاصيل التوصيل",
|
||||||
|
"deliveryDetailsSub": "أدخل تفاصيل توصيل العميل الذي تقوم بإنشاء هذا الطلب له",
|
||||||
|
"billingDetails": "تفاصيل الفاتورة",
|
||||||
|
"billingDetailsSub": "أدخل تفاصيل الفاتورة إذا كانت مختلفة عن تفاصيل التوصيل",
|
||||||
|
"mobileNumber": "رقم الجوال",
|
||||||
|
"enterMobile": "أدخل رقم الجوال",
|
||||||
|
"fullName": "الاسم الكامل",
|
||||||
|
"enterFullName": "أدخل الاسم الكامل",
|
||||||
|
"country": "الدولة",
|
||||||
|
"selectCountry": "بحث واختيار الدولة",
|
||||||
|
"postalCode": "الرمز البريدي",
|
||||||
|
"enterPostalCode": "أدخل الرمز البريدي",
|
||||||
|
"state": "المنطقة/المحافظة",
|
||||||
|
"city": "المدينة",
|
||||||
|
"address": "العنوان",
|
||||||
|
"enterAddress": "أدخل العنوان التفصيلي، رقم الشقة، إلخ",
|
||||||
|
"landmark": "علامة مميزة (اختياري)",
|
||||||
|
"enterLandmark": "مثال: بجوار...",
|
||||||
|
"sameAsDelivery": "نفس عنوان التوصيل",
|
||||||
|
"orderSummary": "ملخص الطلب",
|
||||||
|
"orderSummarySub": "أضف المنتجات وتفاصيل الدفع وراجع الإجماليات.",
|
||||||
|
"productDetails": "تفاصيل المنتج",
|
||||||
|
"addProduct": "إضافة منتج",
|
||||||
|
"paymentDetails": "تفاصيل الدفع",
|
||||||
|
"paymentMethod": "طريقة الدفع",
|
||||||
|
"selectPaymentMethod": "اختر طريقة الدفع",
|
||||||
|
"invoiceValue": "قيمة الفاتورة",
|
||||||
|
"enterAmount": "أدخل المبلغ"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"productName": "اسم المنتج",
|
||||||
|
"productNamePlaceholder": "مثال: قميص، إلكترونيات",
|
||||||
|
"quantity": "الكمية",
|
||||||
|
"deadWeight": "الوزن الفعلي (كجم)",
|
||||||
|
"volumetricWeight": "الوزن الحجمي (كجم)",
|
||||||
|
"length": "الطول (سم)",
|
||||||
|
"breadth": "العرض (سم)",
|
||||||
|
"height": "الارتفاع (سم)",
|
||||||
|
"category": "فئة العنصر",
|
||||||
|
"categoryPlaceholder": "اختر الفئة"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"ordersMarkedReady": "تم وضع علامة {{count}} طلبات كجاهزة للشحن",
|
||||||
|
"manifestCreated": "تم إنشاء بيان لـ {{count}} طلبات",
|
||||||
|
"confirmShipOrder": "هل أنت متأكد أنك تريد وضع علامة على هذا الطلب كجاهز للشحن؟",
|
||||||
|
"confirmBulkShip": "وضع علامة على {{count}} طلبات محددة كجاهزة للشحن؟",
|
||||||
|
"deleteOrderTitle": "حذف الطلب",
|
||||||
|
"confirmDelete": "هل أنت متأكد أنك تريد حذف الطلب {{orderId}}؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"moveToReadyTitle": "نقل إلى جاهز للشحن؟",
|
||||||
|
"moveToReadySingle": "سيتم تحديث الطلب {{orderId}} إلى جاهز للشحن.",
|
||||||
|
"moveToReadyBulk": "سيتم تحديث الطلبات المحددة إلى جاهز للشحن.",
|
||||||
|
"moveToReadyBulkCount": "سيتم تحديث {{count}} طلبات محددة إلى جاهز للشحن.",
|
||||||
|
"selectLocationError": "يرجى اختيار أو إنشاء موقع استلام",
|
||||||
|
"validationError": "يرجى التحقق من الأخطاء في النموذج",
|
||||||
|
"moveToPickedTitle": "نقل إلى تم الالتقاط؟",
|
||||||
|
"moveToPickedSingle": "سيتم تحديث الطلب {{orderId}} إلى تم الالتقاط.",
|
||||||
|
"packageLabeled": "تم وضع علامة على الحزمة كـ 'تم التسمية'",
|
||||||
|
"labelError": "فشل في وضع علامة على الحزمة كـ 'تم التسمية'",
|
||||||
|
"packageReadyToShip": "تم وضع علامة على الحزمة كـ 'جاهزة للشحن'",
|
||||||
|
"updateStatusError": "فشل في تحديث حالة الحزمة",
|
||||||
|
"confirmReadyToShip": "هل أنت متأكد أنك تريد وضع علامة على هذه الحزمة كـ 'جاهزة للشحن'؟"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"packOrder": "تعبئة الطلب {{orderId}}",
|
||||||
|
"shipOrder": "شحن الطلب {{orderId}}",
|
||||||
|
"scanPack": "امسح رمز الاستجابة السريعة هذا لتأكيد التعبئة ووضع علامة تم التعبئة.",
|
||||||
|
"scanShip": "امسح رمز الاستجابة السريعة هذا لوضع علامة على الطلب كجاهز للشحن.",
|
||||||
|
"suggestedPackaging": "التعبئة المقترحة",
|
||||||
|
"dimensions": "الأبعاد",
|
||||||
|
"capacity": "السعة",
|
||||||
|
"noPackagingSuggestion": "لم يتم العثور على اقتراح تعبئة محدد."
|
||||||
|
},
|
||||||
|
"packingWizard": {
|
||||||
|
"title": "جاهز للتعبئة ({{count}})",
|
||||||
|
"recommendedPackaging": "التغليف الموصى به",
|
||||||
|
"use": "استخدم",
|
||||||
|
"verifyItems": "يرجى التحقق من العناصر للطلبات {{count}} التالية قبل التعبئة",
|
||||||
|
"packOrders": "تعبئة الطلبات",
|
||||||
|
"packageCreated": "تم إنشاء الحزمة!",
|
||||||
|
"successMessage": "تم تعبئة الطلبات بنجاح. الآن قم بإنشاء وإرفاق ملصق الشحن.",
|
||||||
|
"printLabel": "طباعة الملصق",
|
||||||
|
"packSuccess": "تم تعبئة الطلبات بنجاح",
|
||||||
|
"packError": "فشل في تعبئة الطلبات"
|
||||||
|
},
|
||||||
|
"schedulePickup": {
|
||||||
|
"title": "جدولة الشحنة",
|
||||||
|
"generateManifestMessage": "إنشاء بيان وجدولة استلام لـ {{count}} طلب(ات).",
|
||||||
|
"selectedPackages": "الحزم المحددة ({{count}})",
|
||||||
|
"selectedOrders": "الطلبات المحددة",
|
||||||
|
"package": "حزمة: {{id}} ({{count}} طلب(ات))",
|
||||||
|
"noOrdersSelected": "لم يتم تحديد أي طلبات. حدد طلبات جاهزة لإنشاء بيان.",
|
||||||
|
"totalOrderValue": "إجمالي قيمة الطلب",
|
||||||
|
"pickupDateTime": "تاريخ ووقت الاستلام",
|
||||||
|
"deliveryMethod": "طريقة التسليم",
|
||||||
|
"selectDeliveryMethod": "اختر طريقة التسليم",
|
||||||
|
"pickupInstructions": "تعليمات الاستلام (اختياري)",
|
||||||
|
"pickupInstructionsPlaceholder": "أضف أي تعليمات خاصة للاستلام...",
|
||||||
|
"generateManifestButton": "إنشاء البيان والجدولة",
|
||||||
|
"deliveryMethods": {
|
||||||
|
"external_carrier": "ناقل خارجي (Delhivery, BlueDart, إلخ)",
|
||||||
|
"self_delivery": "توصيل ذاتي",
|
||||||
|
"courier_pickup": "استلام عبر شريك البريد السريع"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackingDetails": {
|
||||||
|
"title": "تتبع لـ {{id}}",
|
||||||
|
"titleAdd": "إضافة تتبع",
|
||||||
|
"awb": "رقم بوليصة الشحن/التتبع",
|
||||||
|
"enterAwb": "أدخل رقم التتبع",
|
||||||
|
"carrier": "اسم الناقل",
|
||||||
|
"enterCarrier": "اسم شركة الشحن",
|
||||||
|
"url": "رابط التتبع (اختياري)",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"notes": "ملاحظات التتبع (اختياري)",
|
||||||
|
"notesPlaceholder": "أضف أي ملاحظات تتبع...",
|
||||||
|
"expectedDelivery": "تاريخ التوصيل المتوقع",
|
||||||
|
"markInTransit": "وضع علامة في النقل"
|
||||||
|
},
|
||||||
|
"deliveredDetails": {
|
||||||
|
"title": "تم التوصيل - {{id}}",
|
||||||
|
"titleAdd": "وضع علامة تم التوصيل",
|
||||||
|
"deliveredAt": "تم التوصيل في",
|
||||||
|
"receivedBy": "تم الاستلام بواسطة",
|
||||||
|
"receivedByPlaceholder": "اسم المستلم",
|
||||||
|
"proofUrl": "رابط الإثبات",
|
||||||
|
"proofUrlPlaceholder": "https://...",
|
||||||
|
"deliveryNotes": "ملاحظات التوصيل",
|
||||||
|
"deliveryNotesPlaceholder": "أضف ملاحظات التوصيل...",
|
||||||
|
"markDelivered": "وضع علامة تم التوصيل"
|
||||||
|
},
|
||||||
|
"packageLabel": {
|
||||||
|
"title": "ملصق الحزمة",
|
||||||
|
"printLabel": "طباعة الملصق",
|
||||||
|
"scanHint": "امسح لتحديث الحالة أو عرض التفاصيل"
|
||||||
|
},
|
||||||
|
"orderDetails": {
|
||||||
|
"title": "تفاصيل الطلب",
|
||||||
|
"notFound": "الطلب غير موجود",
|
||||||
|
"createdOn": "تم الإنشاء في",
|
||||||
|
"customerDetails": "تفاصيل العميل",
|
||||||
|
"paymentStatus": "الدفع والحالة",
|
||||||
|
"billingDetails": "تفاصيل الفاتورة",
|
||||||
|
"products": "المنتجات",
|
||||||
|
"name": "الاسم",
|
||||||
|
"phone": "الهاتف",
|
||||||
|
"address": "العنوان",
|
||||||
|
"landmark": "علامة مميزة",
|
||||||
|
"totalAmount": "المبلغ الإجمالي",
|
||||||
|
"paymentMethod": "طريقة الدفع",
|
||||||
|
"pickupAddress": "عنوان الاستلام",
|
||||||
|
"productName": "اسم المنتج",
|
||||||
|
"qty": "الكمية",
|
||||||
|
"dimensions": "الأبعاد (الطول × العرض × الارتفاع)",
|
||||||
|
"weights": "الأوزان (الفعلي / الحجمي)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"title": "التغليف",
|
||||||
|
"subTitle": "إدارة مخزون التغليف وأنواعه.",
|
||||||
|
"actions": {
|
||||||
|
"addPackage": "إضافة تغليف",
|
||||||
|
"editPackage": "تعديل التغليف",
|
||||||
|
"createPackage": "إنشاء تغليف",
|
||||||
|
"updatePackage": "تحديث التغليف",
|
||||||
|
"deletePackage": "حذف التغليف",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"edit": "تعديل",
|
||||||
|
"delete": "حذف",
|
||||||
|
"backToPackaging": "العودة إلى التغليف"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"name": "الاسم",
|
||||||
|
"type": "النوع",
|
||||||
|
"dimensions": "الأبعاد (طول×عرض×ارتفاع)",
|
||||||
|
"material": "المادة",
|
||||||
|
"cost": "التكلفة",
|
||||||
|
"status": "الحالة",
|
||||||
|
"actions": "إجراءات"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "الاسم",
|
||||||
|
"namePlaceholder": "مثال: صندوق صغير",
|
||||||
|
"type": "النوع",
|
||||||
|
"typePlaceholder": "مثال: صندوق، ظرف",
|
||||||
|
"sku": "SKU",
|
||||||
|
"skuPlaceholder": "SKU اختياري",
|
||||||
|
"material": "المادة",
|
||||||
|
"materialPlaceholder": "مثال: الورق المقوى",
|
||||||
|
"length": "الطول (سم)",
|
||||||
|
"width": "العرض (سم)",
|
||||||
|
"height": "الارتفاع (سم)",
|
||||||
|
"weightCapacity": "سعة الوزن (كجم)",
|
||||||
|
"costPerUnit": "التكلفة لكل وحدة",
|
||||||
|
"stockQuantity": "كمية المخزون",
|
||||||
|
"activeStatus": "حالة النشاط"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "نشط",
|
||||||
|
"inactive": "غير نشط"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"deleteConfirmation": "هل أنت متأكد أنك تريد حذف عنصر التغليف هذا؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"saveSuccess": "تم حفظ التغليف بنجاح",
|
||||||
|
"deleteSuccess": "تم حذف التغليف بنجاح"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"title": "الملف الشخصي",
|
||||||
|
"subTitle": "عرض تفاصيل حسابك",
|
||||||
|
"sections": {
|
||||||
|
"accountInfo": "معلومات السجل",
|
||||||
|
"appearance": "المظهر",
|
||||||
|
"appearanceDesc": "تخصيص مظهر التطبيق"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"firstName": "الاسم الأول",
|
||||||
|
"lastName": "اسم العائلة",
|
||||||
|
"email": "البريد الإلكتروني",
|
||||||
|
"phone": "رقم الهاتف",
|
||||||
|
"tenant": "المستأجر",
|
||||||
|
"role": "الدور",
|
||||||
|
"accountCreated": "تاريخ إنشاء الحساب",
|
||||||
|
"lastUpdated": "آخر تحديث",
|
||||||
|
"unknown": "غير معروف"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"changePassword": "تغيير كلمة المرور",
|
||||||
|
"editProfile": "تعديل الملف الشخصي",
|
||||||
|
"updatePassword": "تحديث كلمة المرور",
|
||||||
|
"saveChanges": "حفظ التغييرات"
|
||||||
|
},
|
||||||
|
"modals": {
|
||||||
|
"changePasswordTitle": "تغيير كلمة المرور",
|
||||||
|
"editProfileTitle": "تعديل الملف الشخصي",
|
||||||
|
"currentPassword": "كلمة المرور الحالية",
|
||||||
|
"newPassword": "كلمة المرور الجديدة",
|
||||||
|
"confirmPassword": "تأكيد كلمة المرور الجديدة",
|
||||||
|
"currentPasswordPlaceholder": "أدخل كلمة المرور الحالية",
|
||||||
|
"newPasswordPlaceholder": "أدخل كلمة المرور الجديدة (8 أحرف على الأقل)",
|
||||||
|
"confirmPasswordPlaceholder": "أعد إدخال كلمة المرور الجديدة",
|
||||||
|
"firstNamePlaceholder": "أدخل اسمك الأول",
|
||||||
|
"lastNamePlaceholder": "أدخل اسم العائلة",
|
||||||
|
"phonePlaceholder": "أدخل رقم هاتفك"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"passwordSuccess": "تم تحديث كلمة المرور بنجاح",
|
||||||
|
"profileSuccess": "تم تحديث الملف الشخصي بنجاح",
|
||||||
|
"passwordMismatch": "كلمات المرور الجديدة غير متطابقة",
|
||||||
|
"passwordLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
|
||||||
|
"requiredFields": "جميع الحقول مطلوبة",
|
||||||
|
"firstNameRequired": "الاسم الأول مطلوب",
|
||||||
|
"loadError": "تعذر تحميل الملف الشخصي للمستخدم. يرجى محاولة تحديث الصفحة."
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"profileSettings": "إعدادات الملف الشخصي",
|
||||||
|
"signOut": "تسجيل الخروج"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"title": "الأدوار",
|
||||||
|
"add": "إضافة دور",
|
||||||
|
"edit": "تعديل الدور",
|
||||||
|
"delete": "حذف الدور",
|
||||||
|
"details": "تفاصيل الدور",
|
||||||
|
"subTitle": "إنشاء دور جديد وربطه اختياريًا بمستأجر.",
|
||||||
|
"backToRoles": "العودة للأدوار",
|
||||||
|
"columns": {
|
||||||
|
"roleName": "اسم الدور",
|
||||||
|
"tenant": "المستأجر",
|
||||||
|
"created": "تاريخ الإنشاء",
|
||||||
|
"updated": "تاريخ التحديث",
|
||||||
|
"actions": "إجراءات"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"roleName": "اسم الدور",
|
||||||
|
"roleNamePlaceholder": "أدخل اسم الدور",
|
||||||
|
"isDefault": "تعيين كدور افتراضي",
|
||||||
|
"accesses": "الصلاحيات",
|
||||||
|
"tenant": "المستأجر",
|
||||||
|
"tenantPlaceholder": "اختر مستأجر (اختياري)",
|
||||||
|
"tenantLoading": "جاري تحميل المستأجرين...",
|
||||||
|
"tenantAssigned": "المستأجر المعين",
|
||||||
|
"loading": "جاري التحميل..."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create": "إنشاء الدور",
|
||||||
|
"update": "حفظ التغييرات",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"delete": "حذف الدور",
|
||||||
|
"close": "إغلاق",
|
||||||
|
"view": "عرض التفاصيل",
|
||||||
|
"edit": "تعديل الدور"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"deleteConfirm": "سيتم حذف هذا الدور نهائيًا.",
|
||||||
|
"deleteTitle": "حذف الدور؟",
|
||||||
|
"loadError": "تعذر تحميل الأدوار.",
|
||||||
|
"detailsError": "فشل تحميل تفاصيل الدور",
|
||||||
|
"updateError": "فشل تحديث الدور",
|
||||||
|
"deleteError": "فشل حذف الدور",
|
||||||
|
"addError": "تعذر إضافة الدور.",
|
||||||
|
"accessLoadError": "تعذر تحميل خيارات الوصول.",
|
||||||
|
"tenantLoadError": "تعذر تحميل المستأجر.",
|
||||||
|
"tenantsLoadError": "تعذر تحميل المستأجرين.",
|
||||||
|
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا الدور؟"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"title": "المستخدمون",
|
||||||
|
"add": "إضافة مستخدم",
|
||||||
|
"edit": "تعديل المستخدم",
|
||||||
|
"delete": "حذف المستخدم",
|
||||||
|
"details": "تفاصيل المستخدم",
|
||||||
|
"subTitle": "إنشاء مستخدم جديد وتعيين تفاصيل المستأجر والدور.",
|
||||||
|
"backToUsers": "العودة للمستخدمين",
|
||||||
|
"columns": {
|
||||||
|
"name": "الاسم",
|
||||||
|
"email": "البريد الإلكتروني",
|
||||||
|
"phone": "الهاتف",
|
||||||
|
"status": "الحالة",
|
||||||
|
"tenant": "المستأجر",
|
||||||
|
"role": "الدور",
|
||||||
|
"created": "تاريخ الإنشاء",
|
||||||
|
"updated": "تاريخ التحديث",
|
||||||
|
"actions": "إجراءات"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"firstName": "الاسم الأول",
|
||||||
|
"firstNamePlaceholder": "أدخل الاسم الأول",
|
||||||
|
"lastName": "اسم العائلة",
|
||||||
|
"lastNamePlaceholder": "أدخل اسم العائلة",
|
||||||
|
"email": "البريد الإلكتروني",
|
||||||
|
"emailPlaceholder": "user@example.com",
|
||||||
|
"phone": "رقم الهاتف",
|
||||||
|
"phonePlaceholder": "أدخل رقم الهاتف",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"passwordPlaceholder": "أدخل كلمة المرور",
|
||||||
|
"passwordEditPlaceholder": "اتركه فارغًا للاحتفاظ بالحالية",
|
||||||
|
"role": "الدور",
|
||||||
|
"rolePlaceholder": "اختر الدور",
|
||||||
|
"roleSuperAdminPlaceholder": "اختر دور المشرف العام",
|
||||||
|
"roleFilterPlaceholder": "اختر مستأجر لتصفية الأدوار",
|
||||||
|
"tenant": "المستأجر",
|
||||||
|
"tenantPlaceholder": "ختر مستأجر",
|
||||||
|
"tenantLoading": "جاري تحميل المستأجرين...",
|
||||||
|
"roleLoading": "جاري تحميل الأدوار...",
|
||||||
|
"superAdminUser": "مستخدم مشرف عام",
|
||||||
|
"superAdminNoTenant": "مشرف عام (لا يوجد مستأجر)",
|
||||||
|
"assignedTenant": "المستأجر المعين",
|
||||||
|
"active": "نشط"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create": "إنشاء مستخدم",
|
||||||
|
"update": "حفظ التغييرات",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"delete": "حذف المستخدم",
|
||||||
|
"close": "إغلاق",
|
||||||
|
"view": "عرض التفاصيل",
|
||||||
|
"edit": "تعديل المستخدم"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"deleteConfirm": "سيتم حذف هذا المستخدم نهائيًا.",
|
||||||
|
"deleteTitle": "حذف المستخدم؟",
|
||||||
|
"updateSuccess": "تم تحديث المستخدم بنجاح",
|
||||||
|
"createSuccess": "تم إنشاء المستخدم بنجاح",
|
||||||
|
"loadError": "تعذر تحميل المستخدمين.",
|
||||||
|
"addError": "تعذر إضافة المستخدم.",
|
||||||
|
"tenantLoadError": "تعذر تحميل المستأجر.",
|
||||||
|
"tenantsLoadError": "تعذر تحميل المستأجرين.",
|
||||||
|
"rolesLoadError": "تعذر تحميل الأدوار.",
|
||||||
|
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا المستخدم؟"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"orders": "Orders",
|
||||||
|
"locations": "Locations",
|
||||||
|
"packaging": "Packaging",
|
||||||
|
"tenants": "Tenants",
|
||||||
|
"users": "Users",
|
||||||
|
"roles": "Roles",
|
||||||
|
"themes": "Themes",
|
||||||
|
"profile": "Profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"configuration": "Configuration",
|
||||||
|
"dropdowns": "Dropdowns"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"add": "Add",
|
||||||
|
"create": "Create",
|
||||||
|
"update": "Update",
|
||||||
|
"search": "Search",
|
||||||
|
"filter": "Filter",
|
||||||
|
"export": "Export",
|
||||||
|
"import": "Import",
|
||||||
|
"reset": "Reset",
|
||||||
|
"submit": "Submit",
|
||||||
|
"close": "Close",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"view": "View",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"signout": "Sign Out",
|
||||||
|
"logout": "Logout"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"warning": "Warning",
|
||||||
|
"info": "Info",
|
||||||
|
"noData": "No data available",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"selected": "Selected",
|
||||||
|
"total": "Total",
|
||||||
|
"showing": "Showing",
|
||||||
|
"of": "of",
|
||||||
|
"entries": "entries",
|
||||||
|
"page": "Page",
|
||||||
|
"rowsPerPage": "Rows per page",
|
||||||
|
"qty": "QTY",
|
||||||
|
"volumetricWeight": "Volumetric wt."
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"roleName": "Role Name",
|
||||||
|
"tenant": "Tenant",
|
||||||
|
"created": "Created",
|
||||||
|
"updated": "Updated",
|
||||||
|
"actions": "Actions",
|
||||||
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
|
"role": "Role",
|
||||||
|
"status": "Status"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"pending": "Pending",
|
||||||
|
"approved": "Approved",
|
||||||
|
"rejected": "Rejected",
|
||||||
|
"new": "New",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"completed": "Completed",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"language": {
|
||||||
|
"title": "Language Preference",
|
||||||
|
"description": "Choose your preferred language for the application interface",
|
||||||
|
"label": "Language",
|
||||||
|
"selectLabel": "Select Language",
|
||||||
|
"updated": "Language preference updated successfully",
|
||||||
|
"confirmTitle": "Change Language?",
|
||||||
|
"confirmMessage": "The page will reload to apply the new language settings. Continue?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "This field is required",
|
||||||
|
"email": "Please enter a valid email address",
|
||||||
|
"minLength": "Must be at least {{min}} characters",
|
||||||
|
"maxLength": "Must be at most {{max}} characters",
|
||||||
|
"passwordMatch": "Passwords do not match"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saveSuccess": "Saved successfully",
|
||||||
|
"updateSuccess": "Updated successfully",
|
||||||
|
"deleteSuccess": "Deleted successfully",
|
||||||
|
"createSuccess": "Created successfully",
|
||||||
|
"error": "An error occurred. Please try again.",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this item?"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "Dashboard in Progress",
|
||||||
|
"message": "We're crafting a powerful dashboard experience to give you better insights. Please check back soon!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"title": "Dropdowns Configuration",
|
||||||
|
"noPermission": "You do not have permission to view any dropdown configurations.",
|
||||||
|
"tabs": {
|
||||||
|
"locationTypes": "Location Types",
|
||||||
|
"packageTypes": "Package Types",
|
||||||
|
"packageMaterials": "Package Materials",
|
||||||
|
"productCategories": "Product Categories"
|
||||||
|
},
|
||||||
|
"locationTypes": {
|
||||||
|
"title": "Location Types",
|
||||||
|
"addButton": "Add Location Type",
|
||||||
|
"editTitle": "Edit Location Type",
|
||||||
|
"addTitle": "Add Location Type",
|
||||||
|
"deleteTitle": "Delete Location Type?",
|
||||||
|
"deleteMessage": "Are you sure you want to delete this location type? This action cannot be undone.",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter location type name"
|
||||||
|
},
|
||||||
|
"packageTypes": {
|
||||||
|
"title": "Package Types",
|
||||||
|
"addButton": "Add Package Type",
|
||||||
|
"editTitle": "Edit Package Type",
|
||||||
|
"addTitle": "Add Package Type",
|
||||||
|
"deleteTitle": "Delete Package Type?",
|
||||||
|
"deleteMessage": "Are you sure you want to delete this package type? This action cannot be undone.",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter package type name"
|
||||||
|
},
|
||||||
|
"packageMaterials": {
|
||||||
|
"title": "Package Materials",
|
||||||
|
"addButton": "Add Package Material",
|
||||||
|
"editTitle": "Edit Package Material",
|
||||||
|
"addTitle": "Add Package Material",
|
||||||
|
"deleteTitle": "Delete Package Material?",
|
||||||
|
"deleteMessage": "Are you sure you want to delete this package material? This action cannot be undone.",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter package material name"
|
||||||
|
},
|
||||||
|
"productCategories": {
|
||||||
|
"title": "Product Categories",
|
||||||
|
"addButton": "Add Product Category",
|
||||||
|
"editTitle": "Edit Product Category",
|
||||||
|
"addTitle": "Add Product Category",
|
||||||
|
"deleteTitle": "Delete Product Category?",
|
||||||
|
"deleteMessage": "Are you sure you want to delete this product category? This action cannot be undone.",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter product category name"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"title": "Locations",
|
||||||
|
"subTitle": "Manage your warehouse and hub locations.",
|
||||||
|
"headers": {
|
||||||
|
"addLocation": "Add Location",
|
||||||
|
"addNewLocation": "Add New Location",
|
||||||
|
"editLocation": "Edit Location",
|
||||||
|
"noLocationsFound": "No Locations Found",
|
||||||
|
"viewMap": "View Map",
|
||||||
|
"locationOnMap": "Location on Map"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"noLocationsDescription": "Add your warehouse or hub locations to manage pickups efficiently.",
|
||||||
|
"deleteConfirmation": "Are you sure you want to delete this location? This action cannot be undone.",
|
||||||
|
"deleteTitle": "Delete Location?",
|
||||||
|
"saveSuccess": "Location saved successfully",
|
||||||
|
"fetchError": "Failed to fetch locations",
|
||||||
|
"fetchDetailsError": "Failed to fetch location details",
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Location Name",
|
||||||
|
"namePlaceholder": "e.g. Main Warehouse, Hub Unit 1",
|
||||||
|
"type": "Location Type",
|
||||||
|
"locationType": "Location Type",
|
||||||
|
"selectType": "Select Type",
|
||||||
|
"active": "Active",
|
||||||
|
"usedForRouting": "Used for Routing",
|
||||||
|
"warehouse": "Warehouse",
|
||||||
|
"hub": "Hub",
|
||||||
|
"factory": "Factory",
|
||||||
|
"capabilities": "Capabilities",
|
||||||
|
"capabilitiesPlaceholder": "Select capabilities...",
|
||||||
|
"country": "Country",
|
||||||
|
"zipCode": "Pincode/Zip Code",
|
||||||
|
"state": "State",
|
||||||
|
"city": "City",
|
||||||
|
"addressLine1": "Address Line 1",
|
||||||
|
"addressLine1Placeholder": "Street address, building, etc.",
|
||||||
|
"addressLine2": "Address Line 2 (Optional)",
|
||||||
|
"addressLine2Placeholder": "Apartment, suite, unit, etc.",
|
||||||
|
"latitude": "Latitude",
|
||||||
|
"longitude": "Longitude",
|
||||||
|
"selectState": "Select State",
|
||||||
|
"selectCity": "Select City",
|
||||||
|
"autoFilled": "Auto-filled",
|
||||||
|
"enterState": "Enter State",
|
||||||
|
"enterCity": "Enter City"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save Location",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"viewMap": "View Map"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Name is required",
|
||||||
|
"typeRequired": "Type is required",
|
||||||
|
"addressRequired": "Address is required",
|
||||||
|
"zipCodeRequired": "Pincode is required",
|
||||||
|
"cityRequired": "City is required",
|
||||||
|
"stateRequired": "State is required",
|
||||||
|
"countryRequired": "Country is required"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"coldStorage": "Cold Storage",
|
||||||
|
"hazardousMaterials": "Hazardous Materials",
|
||||||
|
"access247": "24/7 Access",
|
||||||
|
"loadingDock": "Loading Dock",
|
||||||
|
"forkliftAvailable": "Forklift Available",
|
||||||
|
"cctvSurveillance": "CCTV Surveillance",
|
||||||
|
"climateControlled": "Climate Controlled",
|
||||||
|
"bondedWarehouse": "Bonded Warehouse",
|
||||||
|
"crossDocking": "Cross Docking",
|
||||||
|
"orderFulfillment": "Order Fulfillment",
|
||||||
|
"inventoryManagement": "Inventory Management",
|
||||||
|
"lastMileDelivery": "Last Mile Delivery",
|
||||||
|
"returnsProcessing": "Returns Processing",
|
||||||
|
"realTimeTracking": "Real-time Tracking",
|
||||||
|
"fdaApproved": "FDA Approved",
|
||||||
|
"customsClearance": "Customs Clearance"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
{
|
||||||
|
"tabs": {
|
||||||
|
"all": "All",
|
||||||
|
"new": "Orders",
|
||||||
|
"picked": "Picked",
|
||||||
|
"packed": "Packed",
|
||||||
|
"ready": "Ready To Ship",
|
||||||
|
"pickups": "Pickups & Manifests",
|
||||||
|
"transit": "In Transit",
|
||||||
|
"delivered": "Delivered"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"order": "Order",
|
||||||
|
"manifestId": "Manifest ID",
|
||||||
|
"expectedDelivery": "Expected Delivery",
|
||||||
|
"customer": "Customer",
|
||||||
|
"product": "Product",
|
||||||
|
"payment": "Payment",
|
||||||
|
"pickupAddress": "Pickup Address",
|
||||||
|
"courier": "Courier",
|
||||||
|
"status": "Status",
|
||||||
|
"action": "Action",
|
||||||
|
"orders": "Orders",
|
||||||
|
"totalValue": "Total Value",
|
||||||
|
"pickup": "Pickup",
|
||||||
|
"deliveryMethod": "Delivery Method"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"addOrder": "Add Order",
|
||||||
|
"updateOrder": "Update Order",
|
||||||
|
"cancelOrder": "Cancel",
|
||||||
|
"createOrder": "Create Order",
|
||||||
|
"bulkSchedule": "Schedule Pickup",
|
||||||
|
"bulkShip": "Ship Now",
|
||||||
|
"schedulePickup": "Schedule Pickup",
|
||||||
|
"shipNow": "Ship Now",
|
||||||
|
"markDelivered": "Mark Delivered",
|
||||||
|
"viewDetails": "View Details",
|
||||||
|
"viewProof": "View Proof",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"scheduleShipment": "Schedule Shipment",
|
||||||
|
"markInTransit": "Mark In Transit",
|
||||||
|
"backToOrders": "Back to Orders",
|
||||||
|
"markPacked": "Mark as Packed",
|
||||||
|
"markReady": "Mark as Ready",
|
||||||
|
"readyToPick": "Ready To Pick",
|
||||||
|
"readyToPack": "Ready To Pack",
|
||||||
|
"readyToShip": "Ready To Ship",
|
||||||
|
"label": "Label",
|
||||||
|
"markLabelled": "Mark as Labeled"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"new": "New",
|
||||||
|
"ready": "Ready",
|
||||||
|
"readyToShip": "Ready To Ship",
|
||||||
|
"readyForTransit": "Ready For Transit",
|
||||||
|
"inTransit": "In Transit",
|
||||||
|
"shipped": "Shipped",
|
||||||
|
"delivered": "Delivered",
|
||||||
|
"pickupScheduled": "Pickup Scheduled"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"orderId": "Order ID",
|
||||||
|
"orderTime": "Order Time",
|
||||||
|
"customerName": "Customer Name",
|
||||||
|
"customerPhone": "Customer Phone",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"volumetricWeight": "Volumetric wt.",
|
||||||
|
"cod": "COD",
|
||||||
|
"prepaid": "Prepaid",
|
||||||
|
"trackingNumber": "Tracking Number",
|
||||||
|
"carrierName": "Carrier Name",
|
||||||
|
"moreItems": "+ {{count}} more items",
|
||||||
|
"receivedBy": "Received by",
|
||||||
|
"pickupAddress": "Pickup Address",
|
||||||
|
"selectLocation": "Select from Saved Locations",
|
||||||
|
"chooseLocation": "Choose a location...",
|
||||||
|
"deliveryDetails": "Delivery Details",
|
||||||
|
"deliveryDetailsSub": "Enter the Delivery Details of your buyer for whom you are making this order",
|
||||||
|
"billingDetails": "Billing Details",
|
||||||
|
"billingDetailsSub": "Enter Billing Details if different from Delivery Details",
|
||||||
|
"mobileNumber": "Mobile Number",
|
||||||
|
"enterMobile": "Enter mobile number",
|
||||||
|
"fullName": "Full Name",
|
||||||
|
"enterFullName": "Enter Full Name",
|
||||||
|
"country": "Country",
|
||||||
|
"selectCountry": "Search and select country",
|
||||||
|
"postalCode": "Postal/Zip Code",
|
||||||
|
"enterPostalCode": "Enter postal code",
|
||||||
|
"state": "State/Province",
|
||||||
|
"city": "City",
|
||||||
|
"address": "Address",
|
||||||
|
"enterAddress": "Enter detailed address, apart. no, etc",
|
||||||
|
"landmark": "Landmark (Optional)",
|
||||||
|
"enterLandmark": "E.g. near ...",
|
||||||
|
"sameAsDelivery": "Same as Delivery Address",
|
||||||
|
"orderSummary": "Order Summary",
|
||||||
|
"orderSummarySub": "Add products, payment details and review totals.",
|
||||||
|
"productDetails": "Product Details",
|
||||||
|
"addProduct": "Add Product",
|
||||||
|
"paymentDetails": "Payment Details",
|
||||||
|
"paymentMethod": "Payment Method",
|
||||||
|
"selectPaymentMethod": "Select payment method",
|
||||||
|
"invoiceValue": "Invoice Value",
|
||||||
|
"enterAmount": "Enter amount"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"productName": "Product Name",
|
||||||
|
"productNamePlaceholder": "E.g. T-Shirt, Electronics",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"deadWeight": "Dead Weight (kg)",
|
||||||
|
"volumetricWeight": "Volumetric Weight (kg)",
|
||||||
|
"length": "Length (cm)",
|
||||||
|
"breadth": "Breadth (cm)",
|
||||||
|
"height": "Height (cm)",
|
||||||
|
"category": "Item Category",
|
||||||
|
"categoryPlaceholder": "Select category"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"ordersMarkedReady": "{{count}} orders marked as Ready to Ship",
|
||||||
|
"manifestCreated": "Manifest created for {{count}} orders",
|
||||||
|
"confirmShipOrder": "Are you sure you want to mark this order as Ready to Ship?",
|
||||||
|
"confirmBulkShip": "Mark {{count}} selected orders as Ready to Ship?",
|
||||||
|
"deleteOrderTitle": "Delete Order",
|
||||||
|
"confirmDelete": "Are you sure you want to delete order {{orderId}}? This action cannot be undone.",
|
||||||
|
"moveToReadyTitle": "Move to Ready To Ship?",
|
||||||
|
"moveToReadySingle": "This will update order {{orderId}} to Ready To Ship.",
|
||||||
|
"moveToReadyBulk": "This will update the selected orders to Ready To Ship.",
|
||||||
|
"moveToReadyBulkCount": "This will update {{count}} selected orders to Ready To Ship.",
|
||||||
|
"selectLocationError": "Please select or create a pickup location",
|
||||||
|
"validationError": "Please check the form for errors",
|
||||||
|
"moveToPickedTitle": "Move to Picked?",
|
||||||
|
"moveToPickedSingle": "This will update order {{orderId}} to Picked.",
|
||||||
|
"packageLabeled": "Package marked as Labeled",
|
||||||
|
"labelError": "Failed to mark package as labeled",
|
||||||
|
"packageReadyToShip": "Package marked Ready To Ship",
|
||||||
|
"updateStatusError": "Failed to update package status",
|
||||||
|
"confirmReadyToShip": "Are you sure you want to mark this package as Ready To Ship?"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"packOrder": "Pack Order {{orderId}}",
|
||||||
|
"shipOrder": "Ship Order {{orderId}}",
|
||||||
|
"scanPack": "Scan this QR code to confirm packaging and mark as Packed.",
|
||||||
|
"scanShip": "Scan this QR code to mark the order as Ready to Ship.",
|
||||||
|
"suggestedPackaging": "Suggested Packaging",
|
||||||
|
"dimensions": "Dims",
|
||||||
|
"capacity": "Capacity",
|
||||||
|
"noPackagingSuggestion": "No specific packaging suggestion found."
|
||||||
|
},
|
||||||
|
"packingWizard": {
|
||||||
|
"title": "Ready to Pack ({{count}})",
|
||||||
|
"recommendedPackaging": "Recommended Packaging",
|
||||||
|
"use": "Use",
|
||||||
|
"verifyItems": "Please verify the items for the following {{count}} order(s) before packing",
|
||||||
|
"packOrders": "Pack Orders",
|
||||||
|
"packageCreated": "Package Created!",
|
||||||
|
"successMessage": "The orders have been successfully packed. Now create and attach the shipping label.",
|
||||||
|
"printLabel": "Print Label",
|
||||||
|
"packSuccess": "Orders packed successfully",
|
||||||
|
"packError": "Failed to pack orders"
|
||||||
|
},
|
||||||
|
"schedulePickup": {
|
||||||
|
"title": "Schedule Shipment",
|
||||||
|
"generateManifestMessage": "Generate manifest and schedule pickup for {{count}} order{{plural}}.",
|
||||||
|
"selectedPackages": "Selected Packages ({{count}})",
|
||||||
|
"selectedOrders": "Selected Orders",
|
||||||
|
"package": "Package: {{id}} ({{count}} order{{plural}})",
|
||||||
|
"noOrdersSelected": "No orders selected. Select ready orders to create a manifest.",
|
||||||
|
"totalOrderValue": "Total Order Value",
|
||||||
|
"pickupDateTime": "Pickup Date & Time",
|
||||||
|
"deliveryMethod": "Delivery Method",
|
||||||
|
"selectDeliveryMethod": "Select delivery method",
|
||||||
|
"pickupInstructions": "Pickup Instructions (Optional)",
|
||||||
|
"pickupInstructionsPlaceholder": "Add any special instructions for the pickup...",
|
||||||
|
"generateManifestButton": "Generate Manifest & Schedule",
|
||||||
|
"deliveryMethods": {
|
||||||
|
"external_carrier": "External Carrier (Delhivery, BlueDart, etc.)",
|
||||||
|
"self_delivery": "Self Delivery",
|
||||||
|
"courier_pickup": "Courier Partner Pickup"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackingDetails": {
|
||||||
|
"title": "Tracking for {{id}}",
|
||||||
|
"titleAdd": "Add Tracking",
|
||||||
|
"awb": "AWB/Tracking Number",
|
||||||
|
"enterAwb": "Enter tracking number",
|
||||||
|
"carrier": "Carrier Name",
|
||||||
|
"enterCarrier": "Courier name",
|
||||||
|
"url": "Tracking URL (Optional)",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"notes": "Tracking Notes (Optional)",
|
||||||
|
"notesPlaceholder": "Add any tracking notes...",
|
||||||
|
"expectedDelivery": "Expected Delivery Date",
|
||||||
|
"markInTransit": "Mark In Transit"
|
||||||
|
},
|
||||||
|
"deliveredDetails": {
|
||||||
|
"title": "Delivered - {{id}}",
|
||||||
|
"titleAdd": "Mark Delivered",
|
||||||
|
"deliveredAt": "Delivered At",
|
||||||
|
"receivedBy": "Received By",
|
||||||
|
"receivedByPlaceholder": "Receiver name",
|
||||||
|
"proofUrl": "Proof URL",
|
||||||
|
"proofUrlPlaceholder": "https://...",
|
||||||
|
"deliveryNotes": "Delivery Notes",
|
||||||
|
"deliveryNotesPlaceholder": "Add delivery notes...",
|
||||||
|
"markDelivered": "Mark Delivered"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"title": "Packaging",
|
||||||
|
"subTitle": "Manage your packaging inventory and types.",
|
||||||
|
"actions": {
|
||||||
|
"addPackage": "Add Package",
|
||||||
|
"editPackage": "Edit Packaging",
|
||||||
|
"createPackage": "Create Package",
|
||||||
|
"updatePackage": "Update Package",
|
||||||
|
"deletePackage": "Delete Package",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"backToPackaging": "Back to Packaging"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"type": "Type",
|
||||||
|
"dimensions": "Dimensions (LxWxH)",
|
||||||
|
"material": "Material",
|
||||||
|
"cost": "Cost",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "e.g. Small Box",
|
||||||
|
"type": "Type",
|
||||||
|
"typePlaceholder": "e.g. Box, Envelope",
|
||||||
|
"sku": "SKU",
|
||||||
|
"skuPlaceholder": "Optional SKU",
|
||||||
|
"material": "Material",
|
||||||
|
"materialPlaceholder": "e.g. Cardboard",
|
||||||
|
"length": "Length (cm)",
|
||||||
|
"width": "Width (cm)",
|
||||||
|
"height": "Height (cm)",
|
||||||
|
"weightCapacity": "Weight Capacity (kg)",
|
||||||
|
"costPerUnit": "Cost Per Unit",
|
||||||
|
"stockQuantity": "Stock Quantity",
|
||||||
|
"activeStatus": "Active Status"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"deleteConfirmation": "Are you sure you want to delete this packaging item? This action cannot be undone.",
|
||||||
|
"saveSuccess": "Packaging saved successfully",
|
||||||
|
"deleteSuccess": "Packaging deleted successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"title": "Profile",
|
||||||
|
"subTitle": "View your account details",
|
||||||
|
"sections": {
|
||||||
|
"accountInfo": "Account Information",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"appearanceDesc": "Customize the look and feel of the application."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"firstName": "First Name",
|
||||||
|
"lastName": "Last Name",
|
||||||
|
"email": "Email Address",
|
||||||
|
"phone": "Phone Number",
|
||||||
|
"tenant": "Tenant",
|
||||||
|
"role": "Role",
|
||||||
|
"accountCreated": "Account Created",
|
||||||
|
"lastUpdated": "Last Updated",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"changePassword": "Change Password",
|
||||||
|
"editProfile": "Edit Profile",
|
||||||
|
"updatePassword": "Update Password",
|
||||||
|
"saveChanges": "Save Changes"
|
||||||
|
},
|
||||||
|
"modals": {
|
||||||
|
"changePasswordTitle": "Change Password",
|
||||||
|
"editProfileTitle": "Edit Profile",
|
||||||
|
"currentPassword": "Current Password",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"confirmPassword": "Confirm New Password",
|
||||||
|
"currentPasswordPlaceholder": "Enter your current password",
|
||||||
|
"newPasswordPlaceholder": "Enter new password (min 8 characters)",
|
||||||
|
"confirmPasswordPlaceholder": "Re-enter new password",
|
||||||
|
"firstNamePlaceholder": "Enter your first name",
|
||||||
|
"lastNamePlaceholder": "Enter your last name",
|
||||||
|
"phonePlaceholder": "Enter your phone number"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"passwordSuccess": "Password updated successfully",
|
||||||
|
"profileSuccess": "Profile updated successfully",
|
||||||
|
"passwordMismatch": "New passwords do not match",
|
||||||
|
"passwordLength": "New password must be at least 8 characters",
|
||||||
|
"requiredFields": "All fields are required",
|
||||||
|
"firstNameRequired": "First name is required",
|
||||||
|
"loadError": "Unable to load user profile. Please try refreshing the page."
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"profileSettings": "Profile Settings",
|
||||||
|
"signOut": "Sign out"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"title": "Roles",
|
||||||
|
"add": "Add Role",
|
||||||
|
"edit": "Edit Role",
|
||||||
|
"delete": "Delete Role",
|
||||||
|
"details": "Role Details",
|
||||||
|
"subTitle": "Create a new role and optionally associate it with a tenant.",
|
||||||
|
"backToRoles": "Back to Roles",
|
||||||
|
"columns": {
|
||||||
|
"roleName": "Role Name",
|
||||||
|
"tenant": "Tenant",
|
||||||
|
"created": "Created",
|
||||||
|
"updated": "Updated",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"roleName": "Role Name",
|
||||||
|
"roleNamePlaceholder": "Enter role name",
|
||||||
|
"isDefault": "Set as Default Role",
|
||||||
|
"accesses": "Accesses",
|
||||||
|
"tenant": "Tenant",
|
||||||
|
"tenantPlaceholder": "Select tenant (optional)",
|
||||||
|
"tenantLoading": "Loading tenants...",
|
||||||
|
"tenantAssigned": "Assigned tenant",
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create": "Create Role",
|
||||||
|
"update": "Save Changes",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete Role",
|
||||||
|
"close": "Close",
|
||||||
|
"view": "View Details",
|
||||||
|
"edit": "Edit Role"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"deleteConfirm": "This role will be permanently removed.",
|
||||||
|
"deleteTitle": "Delete role?",
|
||||||
|
"loadError": "Unable to load roles.",
|
||||||
|
"detailsError": "Failed to load role details",
|
||||||
|
"updateError": "Failed to update role",
|
||||||
|
"deleteError": "Failed to delete role",
|
||||||
|
"addError": "Unable to add role.",
|
||||||
|
"accessLoadError": "Unable to load access options.",
|
||||||
|
"tenantLoadError": "Unable to load tenant.",
|
||||||
|
"tenantsLoadError": "Unable to load tenants.",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this role?"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"title": "Users",
|
||||||
|
"add": "Add User",
|
||||||
|
"edit": "Edit User",
|
||||||
|
"delete": "Delete User",
|
||||||
|
"details": "User Details",
|
||||||
|
"subTitle": "Create a new user and assign tenant and role details.",
|
||||||
|
"backToUsers": "Back to Users",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
|
"status": "Status",
|
||||||
|
"tenant": "Tenant",
|
||||||
|
"role": "Role",
|
||||||
|
"created": "Created At",
|
||||||
|
"updated": "Updated At",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"firstName": "First Name",
|
||||||
|
"firstNamePlaceholder": "Enter first name",
|
||||||
|
"lastName": "Last Name",
|
||||||
|
"lastNamePlaceholder": "Enter last name",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "user@example.com",
|
||||||
|
"phone": "Mobile Number",
|
||||||
|
"phonePlaceholder": "Enter phone number",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Enter password",
|
||||||
|
"passwordEditPlaceholder": "Leave blank to keep current",
|
||||||
|
"role": "Role",
|
||||||
|
"rolePlaceholder": "Select role",
|
||||||
|
"roleSuperAdminPlaceholder": "Select super admin role",
|
||||||
|
"roleFilterPlaceholder": "Select tenant to filter roles",
|
||||||
|
"tenant": "Tenant",
|
||||||
|
"tenantPlaceholder": "Select tenant",
|
||||||
|
"tenantLoading": "Loading tenants...",
|
||||||
|
"roleLoading": "Loading roles...",
|
||||||
|
"superAdminUser": "Super Admin User",
|
||||||
|
"superAdminNoTenant": "Super admin (no tenant)",
|
||||||
|
"assignedTenant": "Assigned tenant",
|
||||||
|
"active": "Active"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create": "Create User",
|
||||||
|
"update": "Save Changes",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete User",
|
||||||
|
"close": "Close",
|
||||||
|
"view": "View Details",
|
||||||
|
"edit": "Edit User"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"deleteConfirm": "This user will be permanently removed.",
|
||||||
|
"deleteTitle": "Delete user?",
|
||||||
|
"updateSuccess": "User updated successfully",
|
||||||
|
"createSuccess": "User created successfully",
|
||||||
|
"loadError": "Unable to load users.",
|
||||||
|
"addError": "Unable to add user.",
|
||||||
|
"tenantLoadError": "Unable to load tenant.",
|
||||||
|
"tenantsLoadError": "Unable to load tenants.",
|
||||||
|
"rolesLoadError": "Unable to load roles.",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this user?"
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap");
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Default Palette (Navy) - fallback */
|
||||||
|
--primary: #3B82F6;
|
||||||
|
--primary-hover: #2563EB;
|
||||||
|
|
||||||
|
--sidebar-bg: #1D2A4B;
|
||||||
|
--sidebar-text: #F1F5F9;
|
||||||
|
--sidebar-text-muted: #94A3B8;
|
||||||
|
--sidebar-active-bg: #0077FF;
|
||||||
|
--sidebar-active-text: #FFFFFF;
|
||||||
|
--sidebar-hover-bg: rgba(255,255,255,0.2);
|
||||||
|
--sidebar-border: #334155;
|
||||||
|
|
||||||
|
--header-bg: #FFFFFF;
|
||||||
|
--header-text: #1F2937;
|
||||||
|
--header-border: #E5E7EB;
|
||||||
|
|
||||||
|
--background: #F9FAFB;
|
||||||
|
--background-secondary: #FFFFFF;
|
||||||
|
|
||||||
|
--table-header-bg: #F8FAFC;
|
||||||
|
--table-row-hover: #F1F5F9;
|
||||||
|
--table-border: #E2E8F0;
|
||||||
|
|
||||||
|
/* Missing defaults */
|
||||||
|
--text-primary: #1F2937;
|
||||||
|
--text-secondary: #64748B;
|
||||||
|
--card-bg: #FFFFFF;
|
||||||
|
--card-border: #E2E8F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: "Outfit", sans-serif;
|
||||||
|
@apply text-base antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Hide number input arrows/spinners */
|
||||||
|
.no-spinner::-webkit-inner-spin-button,
|
||||||
|
.no-spinner::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.no-spinner {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Scrollbar but keep functionality */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL Support */
|
||||||
|
[dir="rtl"] {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL-aware padding and margin utilities */
|
||||||
|
[dir="rtl"] .rtl\:text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .rtl\:text-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mirror flex direction for RTL */
|
||||||
|
[dir="rtl"] .rtl\:flex-row-reverse {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar adjustments for RTL */
|
||||||
|
[dir="rtl"] .sidebar {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .main-content {
|
||||||
|
margin-right: 240px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table RTL alignment */
|
||||||
|
[dir="rtl"] table {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] th,
|
||||||
|
[dir="rtl"] td {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { API_BASE_URL } from "../constant";
|
||||||
|
import { AUTH_COOKIE_KEYS, clearAuthCookies, getCookie } from "./authCookies";
|
||||||
|
import { isTokenExpired } from "./jwt";
|
||||||
|
|
||||||
|
type ApiRequestOptions = Omit<RequestInit, "body"> & {
|
||||||
|
body?: unknown;
|
||||||
|
toast?: boolean;
|
||||||
|
/** Optional custom success message to show in a toast */
|
||||||
|
successMessage?: string;
|
||||||
|
/** Optional custom error message to show in a toast */
|
||||||
|
errorMessage?: string;
|
||||||
|
/** If true, silences all toast notifications for this request */
|
||||||
|
silent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshQueue: (() => void)[] = [];
|
||||||
|
const API_TOAST_OPTIONS = { autoClose: 2000 };
|
||||||
|
|
||||||
|
const hardLogout = () => {
|
||||||
|
clearAuthCookies();
|
||||||
|
toast.error(
|
||||||
|
"Your session has expired. Please sign in again.",
|
||||||
|
API_TOAST_OPTIONS
|
||||||
|
);
|
||||||
|
window.location.href = "/signin";
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAccessToken = async () => {
|
||||||
|
const refreshToken = getCookie(AUTH_COOKIE_KEYS.refresh);
|
||||||
|
if (!refreshToken) throw new Error("No refresh token");
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Refresh failed");
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = async <T>(
|
||||||
|
path: string,
|
||||||
|
options: ApiRequestOptions = {}
|
||||||
|
): Promise<T> => {
|
||||||
|
const {
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
toast: shouldToast = true,
|
||||||
|
successMessage,
|
||||||
|
errorMessage,
|
||||||
|
silent = false,
|
||||||
|
...rest
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const showToast = shouldToast && !silent;
|
||||||
|
|
||||||
|
const accessToken = getCookie(AUTH_COOKIE_KEYS.access);
|
||||||
|
|
||||||
|
if (accessToken && isTokenExpired(accessToken)) {
|
||||||
|
try {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
await refreshAccessToken();
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshQueue.forEach((cb) => cb());
|
||||||
|
refreshQueue = [];
|
||||||
|
} else {
|
||||||
|
await new Promise<void>((resolve) => refreshQueue.push(resolve));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
hardLogout();
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestHeaders = new Headers(headers);
|
||||||
|
let requestBody: BodyInit | undefined;
|
||||||
|
|
||||||
|
if (body !== undefined) {
|
||||||
|
if (body instanceof FormData) {
|
||||||
|
requestBody = body;
|
||||||
|
} else {
|
||||||
|
requestHeaders.set("Content-Type", "application/json");
|
||||||
|
requestBody = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = path.startsWith("http")
|
||||||
|
? path
|
||||||
|
: `${API_BASE_URL.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...rest,
|
||||||
|
headers: requestHeaders,
|
||||||
|
credentials: "include",
|
||||||
|
body: requestBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global 401 handler - ignore for signin requests as they just mean invalid credentials
|
||||||
|
if (response.status === 401 && !url.includes("/api/auth/signin")) {
|
||||||
|
hardLogout();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
const data =
|
||||||
|
response.status === 204
|
||||||
|
? null
|
||||||
|
: contentType.includes("application/json")
|
||||||
|
? await response.json()
|
||||||
|
: await response.text();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Extract the specific error message from the backend response
|
||||||
|
const backendError =
|
||||||
|
typeof data === "object" && data !== null
|
||||||
|
? (data as any).detail || (data as any).message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Prioritize backend error, then the custom errorMessage option, then a default
|
||||||
|
const finalErrorMessage = backendError || errorMessage || "Request failed";
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
toast.error(finalErrorMessage, API_TOAST_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw the actual message so the caller (e.g., SignInForm) can display it
|
||||||
|
throw new Error(finalErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = String(rest.method || "GET").toUpperCase();
|
||||||
|
|
||||||
|
if (showToast && method !== "GET") {
|
||||||
|
const defaultSuccess =
|
||||||
|
method === "POST"
|
||||||
|
? "Created successfully"
|
||||||
|
: method === "PUT" || method === "PATCH"
|
||||||
|
? "Updated successfully"
|
||||||
|
: method === "DELETE"
|
||||||
|
? "Deleted successfully"
|
||||||
|
: "Action successful";
|
||||||
|
|
||||||
|
const msg =
|
||||||
|
(successMessage as string) ?? (data?.message as string) ?? defaultSuccess;
|
||||||
|
toast.success(msg, API_TOAST_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiClient = {
|
||||||
|
get: <T>(path: string, options?: ApiRequestOptions) =>
|
||||||
|
request<T>(path, { ...options, method: "GET" }),
|
||||||
|
|
||||||
|
post: <T>(path: string, body?: unknown, options?: ApiRequestOptions) =>
|
||||||
|
request<T>(path, { ...options, method: "POST", body }),
|
||||||
|
|
||||||
|
put: <T>(path: string, body?: unknown, options?: ApiRequestOptions) =>
|
||||||
|
request<T>(path, { ...options, method: "PUT", body }),
|
||||||
|
|
||||||
|
patch: <T>(path: string, body?: unknown, options?: ApiRequestOptions) =>
|
||||||
|
request<T>(path, { ...options, method: "PATCH", body }),
|
||||||
|
|
||||||
|
delete: <T>(path: string, options?: ApiRequestOptions) =>
|
||||||
|
request<T>(path, { ...options, method: "DELETE" }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {type TokenResponse } from "../application/Authentication/AuthTypes";
|
||||||
|
|
||||||
|
export const AUTH_COOKIE_KEYS = {
|
||||||
|
access: "access_token",
|
||||||
|
refresh: "refresh_token",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCESS_MAX_AGE_SECONDS = 60 * 60; // 1 hour
|
||||||
|
const REFRESH_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
||||||
|
|
||||||
|
const buildCookieOptions = (maxAgeSeconds?: number) => {
|
||||||
|
const options = ["path=/", "samesite=lax"];
|
||||||
|
|
||||||
|
if (typeof maxAgeSeconds === "number") {
|
||||||
|
options.push(`max-age=${maxAgeSeconds}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.protocol === "https:") {
|
||||||
|
options.push("secure");
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.join("; ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCookie = (name: string, value: string, maxAgeSeconds?: number) => {
|
||||||
|
const encodedValue = encodeURIComponent(value);
|
||||||
|
document.cookie = `${name}=${encodedValue}; ${buildCookieOptions(maxAgeSeconds)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCookie = (name: string) => {
|
||||||
|
document.cookie = `${name}=; ${buildCookieOptions(0)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCookie = (name: string) => {
|
||||||
|
const cookies = document.cookie.split(";").map((cookie) => cookie.trim());
|
||||||
|
const match = cookies.find((cookie) => cookie.startsWith(`${name}=`));
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rawValue = match.substring(name.length + 1);
|
||||||
|
return decodeURIComponent(rawValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAuthCookies = (tokens: TokenResponse, rememberMe = false) => {
|
||||||
|
// Store tokens in cookies so they can be reused across page refreshes.
|
||||||
|
const accessMaxAge = rememberMe ? ACCESS_MAX_AGE_SECONDS : undefined;
|
||||||
|
const refreshMaxAge = rememberMe ? REFRESH_MAX_AGE_SECONDS : undefined;
|
||||||
|
|
||||||
|
setCookie(AUTH_COOKIE_KEYS.access, tokens.access_token, accessMaxAge);
|
||||||
|
setCookie(AUTH_COOKIE_KEYS.refresh, tokens.refresh_token, refreshMaxAge);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAuthCookies = () => {
|
||||||
|
// Clear both cookies on logout or auth failure.
|
||||||
|
deleteCookie(AUTH_COOKIE_KEYS.access);
|
||||||
|
deleteCookie(AUTH_COOKIE_KEYS.refresh);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAccessToken = () => getCookie(AUTH_COOKIE_KEYS.access);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const isTokenExpired = (token: string): boolean => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return payload.exp < now;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
export const usePermission = () => {
|
||||||
|
const { hasAccess, isLoading } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
can: (accessCode: string) => {
|
||||||
|
if (isLoading) return false;
|
||||||
|
return hasAccess(accessCode);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { CustomLoader } from "../components/custom";
|
||||||
|
import AppLayout from "../components/layout/AppLayout";
|
||||||
|
|
||||||
|
const ProtectedRoutes = () => {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||||
|
<CustomLoader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block access to protected pages when the user is not signed in.
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/signin" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AppLayout />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoutes;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Route, Routes, Navigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import SignInPage from "../application/Authentication/SignInPage";
|
||||||
|
import SignUpPage from "../application/Authentication/SignUpPage";
|
||||||
|
import ResetPasswordPage from "../application/Authentication/ResetPasswordPage";
|
||||||
|
import Dashboard from "../application/dashboard/Dashboard";
|
||||||
|
import ProfilePage from "../application/profile/ProfilePage";
|
||||||
|
import ProtectedRoutes from "./ProtectedRoutes";
|
||||||
|
|
||||||
|
const AppRoutes = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public Routes */}
|
||||||
|
<Route path="/signin" element={<SignInPage />} />
|
||||||
|
<Route path="/signup" element={<SignUpPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
|
||||||
|
{/* Protected Routes */}
|
||||||
|
<Route element={<ProtectedRoutes />}>
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
{/* Redirect root to dashboard if logged in (ProtectedRoutes handles auth check usually, or we redirect to signin if not) */}
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Catch all - Redirect to dashboard which will handle auth or show dashboard */}
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppRoutes;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react-swc'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(),tailwindcss()],
|
||||||
|
})
|
||||||
|
|
||||||
Reference in New Issue
Block a user