base code with auth

This commit is contained in:
azeeee05
2026-01-19 11:05:40 +05:30
commit 660abff435
91 changed files with 11941 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Local development - for developers on their local machines
VITE_API_BASE_URL=http://localhost:8000
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -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...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>saas_frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4418
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

+128
View File
@@ -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;
+81
View File
@@ -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;
+57
View File
@@ -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;
+87
View File
@@ -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;
+80
View File
@@ -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;
+132
View File
@@ -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;
+64
View File
@@ -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;
+99
View File
@@ -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;
+196
View File
@@ -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;
+102
View File
@@ -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);
}
+194
View File
@@ -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>
);
};
+52
View File
@@ -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;
+81
View File
@@ -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;
+49
View File
@@ -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;
+510
View File
@@ -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;
+130
View File
@@ -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;
+78
View File
@@ -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;
+48
View File
@@ -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,
};
+201
View File
@@ -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;
+40
View File
@@ -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;
+396
View File
@@ -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;
+40
View File
@@ -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>
);
}
+16
View File
@@ -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;
+2
View File
@@ -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;
+130
View File
@@ -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;
};
+91
View File
@@ -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>
);
};
+113
View File
@@ -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;
};
+92
View File
@@ -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;
+110
View File
@@ -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": "هل أنت متأكد أنك تريد حذف هذا العنصر؟"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"title": "لوحة المعلومات قيد التطوير",
"message": "نحن نعمل على إنشاء تجربة لوحة معلومات قوية لمنحك رؤى أفضل. يرجى التحقق مرة أخرى قريبا!"
}
+50
View File
@@ -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": "أدخل اسم فئة المنتج"
}
}
+85
View File
@@ -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": "التخليص الجمركي"
}
}
+234
View File
@@ -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": "الأوزان (الفعلي / الحجمي)"
}
}
+50
View File
@@ -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": "تم حذف التغليف بنجاح"
}
}
+52
View File
@@ -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": "تسجيل الخروج"
}
}
+49
View File
@@ -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": "هل أنت متأكد أنك تريد حذف هذا الدور؟"
}
}
+66
View File
@@ -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": "هل أنت متأكد أنك تريد حذف هذا المستخدم؟"
}
}
+110
View File
@@ -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?"
}
}
+4
View File
@@ -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!"
}
+50
View File
@@ -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"
}
}
+85
View File
@@ -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"
}
}
+209
View File
@@ -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"
}
}
+50
View File
@@ -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"
}
}
+52
View File
@@ -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"
}
}
+49
View File
@@ -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?"
}
}
+66
View File
@@ -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
View File
@@ -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;
}
}
+171
View File
@@ -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" }),
};
+59
View File
@@ -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);
+9
View File
@@ -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;
}
};
+12
View File
@@ -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);
},
};
};
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+25
View File
@@ -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;
+32
View File
@@ -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;
+28
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -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"]
}
+8
View File
@@ -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()],
})