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