initial commit
This commit is contained in:
1
.env.sample
Normal file
1
.env.sample
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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?
|
||||
|
||||
.env
|
||||
73
README.md
Normal file
73
README.md
Normal 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 not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## 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
eslint.config.js
Normal file
23
eslint.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
])
|
||||
BIN
fonts/Vazirmatn[wght].woff2
Normal file
BIN
fonts/Vazirmatn[wght].woff2
Normal file
Binary file not shown.
13
index.html
Normal file
13
index.html
Normal 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>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4310
package-lock.json
generated
Normal file
4310
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.0",
|
||||
"react-date-object": "^2.1.9",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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 |
42
src/App.css
Normal file
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
42
src/App.tsx
Normal file
42
src/App.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from "react-router-dom"
|
||||
import { ThemeProvider } from "./components/ThemeProvider"
|
||||
import { LanguageProvider } from "./components/LanguageProvider"
|
||||
import { Toaster } from "./components/ui/toaster"
|
||||
import { Navbar } from "./components/Navbar"
|
||||
import Auth from "./pages/Auth"
|
||||
import Profile from "./pages/Profile"
|
||||
import Terms from "./pages/Terms"
|
||||
|
||||
const MainLayout = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 transition-colors">
|
||||
<Navbar />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<Auth />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
<Toaster />
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
13
src/api.ts
Normal file
13
src/api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
26
src/api/client.ts
Normal file
26
src/api/client.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { API_BASE_URL } from "../config/constants";
|
||||
|
||||
export const authFetch = async (endpoint: string, options: RequestInit = {}) => {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
|
||||
const isFormData = options.body instanceof FormData;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
...(!isFormData && { "Content-Type": "application/json" }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
80
src/api/users.ts
Normal file
80
src/api/users.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { authFetch } from './client';
|
||||
|
||||
// --- Auth Endpoints ---
|
||||
|
||||
export const loginWithPassword = async (mobile: string, password: string) => {
|
||||
const response = await authFetch('/api/users/login/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mobile, password })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to login with password');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const sendOtp = async (mobile: string, mode: string) => {
|
||||
const response = await authFetch('/api/users/otp/send/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mobile, mode })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to send OTP');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const loginWithOtp = async (mobile: string, otp: string) => {
|
||||
const response = await authFetch('/api/users/otp/login/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mobile, otp })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to login with OTP');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const logoutUser = async (refreshToken: string) => {
|
||||
const response = await authFetch('/api/users/logout/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh: refreshToken })
|
||||
});
|
||||
if (!response.ok) throw new Error("Logout failed");
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// --- Profile Endpoints ---
|
||||
|
||||
export const getUserProfile = async () => {
|
||||
const response = await authFetch('/api/users/me/', {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch profile');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateUserProfile = async (data: Record<string, string>) => {
|
||||
const response = await authFetch('/api/users/me/', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update profile');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateProfilePicture = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', file);
|
||||
|
||||
const response = await authFetch('/api/users/profile/picture/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update profile picture');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const removeProfilePicture = async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', '');
|
||||
|
||||
return authFetch(`/api/users/profile/picture/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
};
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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 |
9
src/components/AppLayout.tsx
Normal file
9
src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react"
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-50 transition-colors">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/LanguageProvider.tsx
Normal file
36
src/components/LanguageProvider.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from "react"
|
||||
|
||||
type Language = "en" | "fa"
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language
|
||||
setLanguage: (lang: Language) => void
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
|
||||
|
||||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<Language>(
|
||||
(localStorage.getItem("language") as Language) || "fa"
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("language", language)
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === "fa" ? "rtl" : "ltr"
|
||||
}, [language])
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useLanguage must be used within a LanguageProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
86
src/components/Navbar.tsx
Normal file
86
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
import { Button } from "./ui/button"
|
||||
import { SettingsMenu } from "./SettingsMenu"
|
||||
import { LogOut } from "lucide-react"
|
||||
import { logoutUser } from "../api/users"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function Navbar() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const refreshToken = localStorage.getItem("refreshToken")
|
||||
if (refreshToken) {
|
||||
await logoutUser(refreshToken)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Logout API failed:", error)
|
||||
} finally {
|
||||
localStorage.removeItem("accessToken")
|
||||
localStorage.removeItem("refreshToken")
|
||||
setShowLogoutModal(false)
|
||||
toast.success(t.logoutToast || "Successfully logged out!")
|
||||
navigate("/login")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-6 py-4 flex items-center justify-between transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded bg-blue-600 flex items-center justify-center text-white font-bold">
|
||||
Q
|
||||
</div>
|
||||
<span className="font-bold text-xl tracking-tight text-slate-900 dark:text-slate-50">Qlockify</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<SettingsMenu />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowLogoutModal(true)}
|
||||
className="text-red-500 dark:text-red-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50"
|
||||
title={t.logout || "Logout"}
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showLogoutModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4" onClick={() => setShowLogoutModal(false)}>
|
||||
<div className="w-full max-w-sm rounded-lg bg-white p-6 shadow-lg dark:bg-slate-900 border dark:border-slate-800" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="mb-2 text-lg font-bold text-slate-900 dark:text-white">
|
||||
{t.confirmLogoutTitle || "Confirm Logout"}
|
||||
</h2>
|
||||
<p className="mb-6 text-slate-600 dark:text-slate-400">
|
||||
{t.confirmLogoutMessage || "Are you sure you want to log out of your account?"}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowLogoutModal(false)}
|
||||
className="dark:text-white"
|
||||
>
|
||||
{t.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleLogout}
|
||||
className="bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700"
|
||||
>
|
||||
{t.logout || "Logout"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
src/components/SettingsMenu.tsx
Normal file
31
src/components/SettingsMenu.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Moon, Sun, Languages } from "lucide-react"
|
||||
import { Button } from "./ui/button"
|
||||
import { useTheme } from "./ThemeProvider"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
|
||||
export function SettingsMenu() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { lang, setLanguage } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLanguage(lang === "fa" ? "en" : "fa")}
|
||||
className="text-slate-900 dark:text-slate-50 font-bold"
|
||||
>
|
||||
<Languages className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
src/components/ThemeProvider.tsx
Normal file
59
src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>({
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
})
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeProviderContext)
|
||||
17
src/components/ThemeToggle.tsx
Normal file
17
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "./ThemeProvider"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 bg-white transition-colors hover:bg-slate-100 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all text-slate-900 dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all text-slate-50 dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
59
src/components/ui/JalaliDatePicker.tsx
Normal file
59
src/components/ui/JalaliDatePicker.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import DatePicker, { DateObject } from "react-multi-date-picker"
|
||||
import persian from "react-date-object/calendars/persian"
|
||||
import persian_fa from "react-date-object/locales/persian_fa"
|
||||
import gregorian from "react-date-object/calendars/gregorian"
|
||||
import gregorian_en from "react-date-object/locales/gregorian_en"
|
||||
import "react-multi-date-picker/styles/backgrounds/bg-dark.css"
|
||||
|
||||
interface JalaliDatePickerProps {
|
||||
value: string | null | undefined;
|
||||
onChange: (date: string) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function JalaliDatePicker({ value, onChange, label, disabled }: JalaliDatePickerProps) {
|
||||
const isFa = document.documentElement.dir === 'rtl'
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
|
||||
|
||||
// Listen for dark mode changes dynamically (optional but good for UX)
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'))
|
||||
})
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleChange = (date: DateObject | null) => {
|
||||
if (!date) {
|
||||
onChange("")
|
||||
} else {
|
||||
// Always output standard Gregorian "YYYY-MM-DD" for backend
|
||||
onChange(date.convert(gregorian, gregorian_en).format("YYYY-MM-DD"))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="text-sm font-medium dark:text-slate-300 mb-1 block">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<DatePicker
|
||||
value={value ? new Date(value) : null}
|
||||
onChange={handleChange}
|
||||
calendar={isFa ? persian : gregorian}
|
||||
locale={isFa ? persian_fa : gregorian_en}
|
||||
inputClass="w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
containerClassName="w-full"
|
||||
className={isDark ? "bg-dark" : ""}
|
||||
calendarPosition="bottom-right"
|
||||
fixMainPosition
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/components/ui/button.tsx
Normal file
52
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-quera-blue text-white hover:bg-quera-hover dark:bg-quera-blue dark:text-white dark:hover:bg-quera-hover",
|
||||
destructive: "bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
|
||||
outline: "border border-slate-200 bg-white text-slate-900 hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:hover:bg-slate-800 dark:hover:text-slate-50",
|
||||
secondary: "bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
|
||||
ghost: "text-slate-900 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-50 dark:hover:bg-slate-800 dark:hover:text-slate-50",
|
||||
link: "text-quera-blue underline-offset-4 hover:underline dark:text-blue-400",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
25
src/components/ui/card.tsx
Normal file
25
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// src/components/ui/card.tsx
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("rounded-lg border border-slate-200 bg-white text-slate-950 shadow-sm", className)} {...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent }
|
||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// src/components/ui/input.tsx
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 px-3 py-2 text-sm ring-offset-white dark:ring-offset-slate-950 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 dark:placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 dark:focus-visible:ring-slate-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
21
src/components/ui/toaster.tsx
Normal file
21
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
import { useTheme } from "../ThemeProvider"
|
||||
import { useTranslation } from "../../hooks/useTranslation"
|
||||
|
||||
export function Toaster() {
|
||||
const { theme } = useTheme()
|
||||
const { lang } = useTranslation()
|
||||
|
||||
const isFa = lang === "fa"
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as "light" | "dark" | "system"}
|
||||
className="toaster group"
|
||||
richColors
|
||||
position={isFa ? "top-left" : "top-right"}
|
||||
dir={isFa ? "rtl" : "ltr"}
|
||||
closeButton
|
||||
/>
|
||||
)
|
||||
}
|
||||
1
src/config/constants.ts
Normal file
1
src/config/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
|
||||
71
src/context/AppContext.tsx
Normal file
71
src/context/AppContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// src/context/AppContext.tsx
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
phone_number: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AppContextType {
|
||||
user: User | null;
|
||||
workspaces: Workspace[];
|
||||
activeWorkspace: Workspace | null;
|
||||
setActiveWorkspace: (ws: Workspace) => void;
|
||||
fetchInitialData: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextType | null>(null);
|
||||
|
||||
export const AppProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null);
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const [userRes, wsRes] = await Promise.all([
|
||||
api.get('/api/users/me/'),
|
||||
api.get('/api/workspaces/')
|
||||
]);
|
||||
|
||||
setUser(userRes.data);
|
||||
setWorkspaces(wsRes.data.results || wsRes.data);
|
||||
|
||||
const savedWsId = localStorage.getItem('active_workspace');
|
||||
const targetWs = wsRes.data.find((w: Workspace) => w.id === savedWsId) || wsRes.data[0];
|
||||
|
||||
if (targetWs) {
|
||||
setActiveWorkspace(targetWs);
|
||||
localStorage.setItem('active_workspace', targetWs.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('accessToken')) {
|
||||
fetchInitialData();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ user, workspaces, activeWorkspace, setActiveWorkspace, fetchInitialData }}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAppContext = () => {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) throw new Error('useAppContext must be used within AppProvider');
|
||||
return context;
|
||||
};
|
||||
15
src/hooks/useTranslation.ts
Normal file
15
src/hooks/useTranslation.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useLanguage } from "../components/LanguageProvider"
|
||||
import { en } from "../locales/en"
|
||||
import { fa } from "../locales/fa"
|
||||
|
||||
const dictionaries = { en, fa }
|
||||
|
||||
export function useTranslation() {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
|
||||
return {
|
||||
t: dictionaries[language],
|
||||
lang: language,
|
||||
setLanguage
|
||||
}
|
||||
}
|
||||
35
src/index.css
Normal file
35
src/index.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: "Vazirmatn";
|
||||
src: url("/fonts/Vazirmatn[wght].woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-quera-blue: #2563eb;
|
||||
--font-sans: "Vazirmatn", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Automatically apply Vazirmatn font when language is Persian */
|
||||
:lang(fa) {
|
||||
font-family: "Vazirmatn", system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
103
src/locales/en.ts
Normal file
103
src/locales/en.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
export const en = {
|
||||
login: {
|
||||
welcome: "Welcome to Qlockify",
|
||||
enterPassword: "Enter your password",
|
||||
verifyNumber: "Verify your number",
|
||||
enterMobileDesc: "Enter your mobile number to continue",
|
||||
signInDesc: "Sign in using your account password",
|
||||
sentCodeDesc: (mobile: string) => `We sent a 6-digit code to ${mobile}`,
|
||||
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
|
||||
continueWithPassword: "Continue with Password",
|
||||
orContinueWith: "Or continue with",
|
||||
otpLogin: "OTP Login",
|
||||
register: "Register",
|
||||
passwordPlaceholder: "Password",
|
||||
signIn: "Sign In",
|
||||
back: "Back",
|
||||
otpPlaceholder: "6-digit code",
|
||||
verifyAndContinue: "Verify & Continue",
|
||||
terms: "By clicking continue, you agree to our Terms of Service and Privacy Policy.",
|
||||
brandingQuote: "Manage your time and workspaces efficiently with our minimal, fast, and secure platform.",
|
||||
toasts: {
|
||||
enterMobile: "Please enter your mobile number",
|
||||
verifySent: "Verification code sent!",
|
||||
failedOtp: "Failed to send OTP",
|
||||
fillAll: "Please fill all fields",
|
||||
successLogin: "Successfully logged in!",
|
||||
invalidCreds: "Invalid credentials",
|
||||
enterOtp: "Please enter the OTP code",
|
||||
invalidOtp: "Invalid OTP code"
|
||||
}
|
||||
},
|
||||
loginTerms: {
|
||||
prefix: "By logging in, you agree to our ",
|
||||
link: "Terms of Service and Privacy Policy",
|
||||
suffix: ""
|
||||
},
|
||||
terms: {
|
||||
back: "Back",
|
||||
title: "Terms of Service and Privacy Policy",
|
||||
lastUpdated: "Last Updated: March 12, 2026",
|
||||
sections: {
|
||||
acceptance: {
|
||||
title: "1. Acceptance of Terms",
|
||||
content: "By accessing and using Qlockify, you agree to be bound by these Terms of Service and all applicable laws and regulations. If you do not agree with any of these terms, you are prohibited from using or accessing this site."
|
||||
},
|
||||
license: {
|
||||
title: "2. User License and Responsibilities",
|
||||
items: [
|
||||
"You must provide accurate, current, and complete information during the registration process.",
|
||||
"You are responsible for maintaining the security of your account and password.",
|
||||
"You may not use the service for any illegal or unauthorized purpose.",
|
||||
"Your use of the service must not violate any laws in your jurisdiction."
|
||||
]
|
||||
},
|
||||
privacy: {
|
||||
title: "3. Privacy Policy & Data Collection",
|
||||
p1: "We take your privacy seriously. We collect information to provide better services to our users. The types of personal data we collect include:",
|
||||
personalLabel: "Personal Information",
|
||||
personalText: "Name, email address, phone number, and birth date provided during registration or profile updates.",
|
||||
usageLabel: "Usage Data",
|
||||
usageText: "Information on how the service is accessed and used, including timestamps and device metrics.",
|
||||
p2: "We do not sell, trade, or rent your personal identification information to others. We employ industry-standard security measures to protect against unauthorized access, alteration, disclosure, or destruction of your personal data."
|
||||
},
|
||||
liability: {
|
||||
title: "4. Limitation of Liability",
|
||||
content: "In no event shall Qlockify or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use the materials on the platform."
|
||||
},
|
||||
modifications: {
|
||||
title: "5. Modifications",
|
||||
content: "We may revise these terms of service at any time without notice. By using this website, you are agreeing to be bound by the then-current version of these terms of service."
|
||||
},
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
"title": "User Profile",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
"email": "Email",
|
||||
"description": "Description",
|
||||
"mobileNumber": "Mobile Number",
|
||||
"birthDate": "Birth Date",
|
||||
"yearsOld": "Years Old",
|
||||
"dateJoined": "Date Joined",
|
||||
"editInfo": "Edit Info",
|
||||
"changePicture": "Change Picture",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"upload": "Upload",
|
||||
"remove": "Remove",
|
||||
"imageInput": "Click to select or drag & drop",
|
||||
toasts: {
|
||||
successEdit: "Profile updated successfully!",
|
||||
successImage: "Profile picture updated!",
|
||||
successRemoveImage: "Profile picture removed!",
|
||||
error: "Something went wrong!"
|
||||
}
|
||||
},
|
||||
logout: "Logout",
|
||||
logoutToast: "Successfully logged out!",
|
||||
confirmLogoutTitle: "Confirm Logout",
|
||||
confirmLogoutMessage: "Are you sure you want to log out of your account?",
|
||||
cancel: "Cancel",
|
||||
}
|
||||
104
src/locales/fa.ts
Normal file
104
src/locales/fa.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
export const fa = {
|
||||
login: {
|
||||
welcome: "به Qlockify خوش آمدید",
|
||||
enterPassword: "رمز عبور خود را وارد کنید",
|
||||
verifyNumber: "تایید شماره موبایل",
|
||||
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
|
||||
signInDesc: "با استفاده از رمز عبور خود وارد شوید",
|
||||
sentCodeDesc: (mobile: string) => `کد ۶ رقمی به ${mobile} ارسال شد`,
|
||||
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
|
||||
continueWithPassword: "ادامه با رمز عبور",
|
||||
orContinueWith: "یا ادامه با",
|
||||
otpLogin: "ورود با کد یکبار مصرف",
|
||||
register: "ثبت نام",
|
||||
passwordPlaceholder: "رمز عبور",
|
||||
signIn: "ورود",
|
||||
back: "بازگشت",
|
||||
otpPlaceholder: "کد ۶ رقمی",
|
||||
verifyAndContinue: "تایید و ادامه",
|
||||
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت میکنید.",
|
||||
brandingQuote: "زمان و فضاهای کاری خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
||||
toasts: {
|
||||
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
|
||||
verifySent: "کد تایید ارسال شد!",
|
||||
failedOtp: "ارسال کد تایید با خطا مواجه شد",
|
||||
fillAll: "لطفا تمام فیلدها را پر کنید",
|
||||
successLogin: "با موفقیت وارد شدید!",
|
||||
invalidCreds: "اطلاعات ورود نامعتبر است",
|
||||
enterOtp: "لطفا کد تایید را وارد کنید",
|
||||
invalidOtp: "کد تایید نامعتبر است"
|
||||
}
|
||||
},
|
||||
loginTerms: {
|
||||
prefix: "با ورود به سیستم، شما با ",
|
||||
link: "شرایط خدمات و حریم خصوصی",
|
||||
suffix: " ما موافقت میکنید."
|
||||
},
|
||||
terms: {
|
||||
back: "بازگشت",
|
||||
title: "شرایط خدمات و حریم خصوصی",
|
||||
lastUpdated: "آخرین بروزرسانی: ۲۱ اسفند ۱۴۰۴",
|
||||
sections: {
|
||||
acceptance: {
|
||||
title: "۱. پذیرش شرایط",
|
||||
content: "با دسترسی و استفاده از Qlockify، شما موافقت میکنید که به این شرایط خدمات و تمامی قوانین و مقررات مربوطه پایبند باشید. اگر با هر یک از این شرایط موافق نیستید، استفاده یا دسترسی شما به این سایت ممنوع است."
|
||||
},
|
||||
license: {
|
||||
title: "۲. مجوز کاربر و مسئولیتها",
|
||||
items: [
|
||||
"شما باید اطلاعات دقیق، بهروز و کامل را در طول فرآیند ثبتنام ارائه دهید.",
|
||||
"شما مسئول حفظ امنیت حساب و رمز عبور خود هستید.",
|
||||
"شما مجاز به استفاده از خدمات برای اهداف غیرقانونی یا غیرمجاز نیستید.",
|
||||
"استفاده شما از خدمات نباید قوانین حوزه قضایی شما را نقض کند."
|
||||
]
|
||||
},
|
||||
privacy: {
|
||||
title: "۳. حریم خصوصی و جمعآوری دادهها",
|
||||
p1: "ما حریم خصوصی شما را جدی میگیریم. ما اطلاعاتی را برای ارائه خدمات بهتر به کاربران خود جمعآوری میکنیم. انواع دادههای شخصی که جمعآوری میکنیم شامل موارد زیر است:",
|
||||
personalLabel: "اطلاعات شخصی",
|
||||
personalText: "نام، آدرس ایمیل، شماره تلفن و تاریخ تولد ارائهشده در طول ثبتنام یا بهروزرسانی پروفایل.",
|
||||
usageLabel: "دادههای استفاده",
|
||||
usageText: "اطلاعات مربوط به نحوه دسترسی و استفاده از خدمات، از جمله زمانسنجیها و معیارهای دستگاه.",
|
||||
p2: "ما اطلاعات شناسایی شخصی شما را به دیگران نمیفروشیم، مبادله نمیکنیم یا اجاره نمیدهیم. ما از اقدامات امنیتی استاندارد صنعت برای محافظت در برابر دسترسی غیرمجاز، تغییر، افشا یا تخریب دادههای شخصی شما استفاده میکنیم."
|
||||
},
|
||||
liability: {
|
||||
title: "۴. محدودیت مسئولیت",
|
||||
content: "در هیچ شرایطی Qlockify یا تأمینکنندگان آن مسئولیتی در قبال هرگونه خسارت (از جمله، بدون محدودیت، خسارت ناشی از دست دادن دادهها یا سود، یا به دلیل وقفه در کسبوکار) ناشی از استفاده یا عدم توانایی استفاده از مواد روی پلتفرم نخواهند داشت."
|
||||
},
|
||||
modifications: {
|
||||
title: "۵. اصلاحات",
|
||||
content: "ما ممکن است این شرایط خدمات را در هر زمان بدون اطلاع قبلی بازبینی کنیم. با استفاده از این وبسایت، شما موافقت میکنید که به نسخه فعلی این شرایط خدمات پایبند باشید."
|
||||
},
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
"title": "پروفایل کاربر",
|
||||
"firstName": "نام",
|
||||
"lastName": "نام خانوادگی",
|
||||
"email": "ایمیل",
|
||||
"description": "توضیحات",
|
||||
"mobileNumber": "شماره موبایل",
|
||||
"birthDate": "تاریخ تولد",
|
||||
"yearsOld": "سال",
|
||||
"dateJoined": "تاریخ عضویت",
|
||||
"editInfo": "ویرایش اطلاعات",
|
||||
"changePicture": "تغییر تصویر",
|
||||
"save": "ذخیره",
|
||||
"cancel": "لغو",
|
||||
"upload": "آپلود",
|
||||
"remove": "حذف",
|
||||
"imageInput": "برای انتخاب کلیک کنید یا فایل را بکشید",
|
||||
"noEmail": "ایمیلی ثبت نشده",
|
||||
toasts: {
|
||||
successEdit: "پروفایل با موفقیت بروزرسانی شد!",
|
||||
successImage: "عکس پروفایل بروزرسانی شد!",
|
||||
successRemoveImage: "عکس پروفایل حذف شد!",
|
||||
error: "خطایی رخ داد!"
|
||||
}
|
||||
},
|
||||
logout: "خروج",
|
||||
logoutToast: "با موفقیت خارج شدید!",
|
||||
confirmLogoutTitle: "تایید خروج",
|
||||
confirmLogoutMessage: "آیا مطمئن هستید که میخواهید از حساب خود خارج شوید؟",
|
||||
cancel: "لغو",
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import App from "./App.tsx"
|
||||
import "./index.css"
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
233
src/pages/Auth.tsx
Normal file
233
src/pages/Auth.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useState } from "react"
|
||||
import { useNavigate, Link } from "react-router-dom"
|
||||
import { Button } from "../components/ui/button"
|
||||
import { Input } from "../components/ui/input"
|
||||
import { SettingsMenu } from "../components/SettingsMenu"
|
||||
import { ArrowLeft, ArrowRight, Command, Loader2, Eye, EyeOff } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
import { loginWithPassword, sendOtp, loginWithOtp } from "../api/users"
|
||||
|
||||
type AuthStep = "mobile" | "password" | "otp"
|
||||
type AuthMode = "login" | "register"
|
||||
|
||||
export default function Auth() {
|
||||
const navigate = useNavigate()
|
||||
const { t, lang } = useTranslation()
|
||||
const isRtl = lang === "fa"
|
||||
|
||||
const [step, setStep] = useState<AuthStep>("mobile")
|
||||
const [mode, setMode] = useState<AuthMode>("login")
|
||||
const [mobile, setMobile] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [otpCode, setOtpCode] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false) // Added state for password visibility
|
||||
|
||||
const handleTokenResponse = (access: string, refresh: string) => {
|
||||
localStorage.setItem("accessToken", access)
|
||||
localStorage.setItem("refreshToken", refresh)
|
||||
toast.success(t.login.toasts.successLogin)
|
||||
navigate("/profile")
|
||||
}
|
||||
|
||||
const handleSendOtp = async (selectedMode: AuthMode) => {
|
||||
if (!mobile) return toast.error(t.login.toasts.enterMobile)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await sendOtp(mobile, selectedMode)
|
||||
setMode(selectedMode)
|
||||
setStep("otp")
|
||||
toast.success(t.login.toasts.verifySent)
|
||||
} catch (err: any) {
|
||||
toast.error(t.login.toasts.failedOtp)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!mobile || !password) return toast.error(t.login.toasts.fillAll)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await loginWithPassword(mobile, password)
|
||||
handleTokenResponse(data.access, data.refresh)
|
||||
} catch (err: any) {
|
||||
toast.error(t.login.toasts.invalidCreds)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOtpVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!mobile || !otpCode) return toast.error(t.login.toasts.enterOtp)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await loginWithOtp(mobile, otpCode)
|
||||
handleTokenResponse(data.access, data.refresh)
|
||||
} catch (err: any) {
|
||||
toast.error(t.login.toasts.invalidOtp)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft
|
||||
|
||||
return (
|
||||
<div className="container relative min-h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0 bg-white dark:bg-slate-950 transition-colors">
|
||||
|
||||
<div className="absolute inset-e-4 top-4 z-50 md:inset-e-8 md:top-8">
|
||||
<SettingsMenu />
|
||||
</div>
|
||||
|
||||
<div className="relative hidden h-full flex-col bg-slate-900 dark:bg-slate-900/50 p-10 text-white lg:flex border-e border-slate-200 dark:border-slate-800">
|
||||
<div className="relative z-20 flex items-center text-lg font-medium gap-2">
|
||||
<Command className="h-6 w-6" />
|
||||
Qlockify
|
||||
</div>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg">"{t.login.brandingQuote}"</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 lg:p-8 flex h-screen items-center justify-center">
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-87.5">
|
||||
|
||||
<div className="flex flex-col space-y-2 text-center text-slate-900 dark:text-slate-50">
|
||||
<div className="flex justify-center lg:hidden mb-4">
|
||||
<Command className="h-8 w-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{step === "mobile" && t.login.welcome}
|
||||
{step === "password" && t.login.enterPassword}
|
||||
{step === "otp" && t.login.verifyNumber}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{step === "mobile" && t.login.enterMobileDesc}
|
||||
{step === "password" && t.login.signInDesc}
|
||||
{step === "otp" && t.login.sentCodeDesc(mobile)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{step === "mobile" && (
|
||||
<div className="grid gap-4">
|
||||
<Input
|
||||
id="mobile"
|
||||
placeholder={t.login.mobilePlaceholder}
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
value={mobile}
|
||||
onChange={(e) => setMobile(e.target.value)}
|
||||
maxLength={11}
|
||||
disabled={loading}
|
||||
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
|
||||
/>
|
||||
<Button onClick={() => { if (!mobile) return toast.error(t.login.toasts.enterMobile); setStep("password") }} className="w-full h-11">
|
||||
{t.login.continueWithPassword}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-slate-200 dark:border-slate-800" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-slate-950 px-2 text-slate-500 dark:text-slate-400 transition-colors">
|
||||
{t.login.orContinueWith}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button variant="outline" onClick={() => handleSendOtp("login")} disabled={loading} className="h-11">
|
||||
{loading && mode === "login" && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||
{t.login.otpLogin}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleSendOtp("register")} disabled={loading} className="h-11">
|
||||
{loading && mode === "register" && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||
{t.login.register}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "password" && (
|
||||
<form onSubmit={handlePasswordLogin} autoComplete="off" className="grid gap-4">
|
||||
<div className="relative w-full" dir="ltr">
|
||||
<Input
|
||||
id="password"
|
||||
placeholder={t.login.passwordPlaceholder}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
dir="ltr"
|
||||
name="some-random-name-to-disable-auto-complete-on-browser"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
className={`h-11 pr-10 ${isRtl ? "text-end" : "text-start"}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={loading}>
|
||||
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />} {t.login.signIn}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setStep("mobile")} className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<BackIcon className="me-2 h-4 w-4" /> {t.login.back}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "otp" && (
|
||||
<form onSubmit={handleOtpVerify} className="grid gap-4">
|
||||
<Input
|
||||
id="otp"
|
||||
placeholder={t.login.otpPlaceholder}
|
||||
type="text"
|
||||
dir="ltr"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value)}
|
||||
maxLength={6}
|
||||
disabled={loading}
|
||||
className="h-11 text-center tracking-widest text-lg"
|
||||
/>
|
||||
<Button type="submit" className="w-full h-11" disabled={loading}>
|
||||
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />} {t.login.verifyAndContinue}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setStep("mobile")} className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<BackIcon className="me-2 h-4 w-4" /> {t.login.back}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.loginTerms?.prefix}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{t.loginTerms?.link}
|
||||
</Link>
|
||||
{t.loginTerms?.suffix}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
423
src/pages/Profile.tsx
Normal file
423
src/pages/Profile.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
import {
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
updateProfilePicture,
|
||||
removeProfilePicture
|
||||
} from "../api/users"
|
||||
import { Button } from "../components/ui/button"
|
||||
import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud } from "lucide-react"
|
||||
import JalaliDatePicker from "../components/ui/JalaliDatePicker"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export interface UserProfile {
|
||||
id?: string;
|
||||
email?: string;
|
||||
mobile?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
full_name?: string;
|
||||
description?: string;
|
||||
birth_date?: string;
|
||||
age?: number;
|
||||
profile_picture?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export default function Profile() {
|
||||
const navigate = useNavigate()
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const { t, lang } = useTranslation()
|
||||
const isFa = lang === 'fa'
|
||||
|
||||
const toPersianNum = (num: string | number | undefined | null) => {
|
||||
if (num === null || num === undefined) return num
|
||||
if (!isFa) return num
|
||||
return num.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d as any])
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | undefined) => {
|
||||
if (!dateStr) return "-"
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
return new Intl.DateTimeFormat(isFa ? 'fa-IR' : 'en-US', {
|
||||
dateStyle: 'long',
|
||||
timeZone: 'Asia/Tehran'
|
||||
}).format(date)
|
||||
} catch (e) {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// Modals state
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [isPicModalOpen, setIsPicModalOpen] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Form states
|
||||
const [formData, setFormData] = useState<Partial<UserProfile>>({})
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const data = await getUserProfile()
|
||||
setUser(data)
|
||||
} catch (error) {
|
||||
navigate("/login")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile()
|
||||
}, [])
|
||||
|
||||
const handleEditClick = () => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
email: user.email || "",
|
||||
description: user.description || "",
|
||||
birth_date: user.birth_date || "",
|
||||
})
|
||||
setIsEditModalOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const payload: Record<string, any> = {}
|
||||
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (key === "birth_date" && value === "") {
|
||||
payload[key] = null
|
||||
} else if (value !== undefined && value !== null) {
|
||||
payload[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
const updatedUser = await updateUserProfile(payload)
|
||||
setUser(prev => prev ? { ...prev, ...updatedUser } : updatedUser)
|
||||
setIsEditModalOpen(false)
|
||||
toast.success(t.profile.toasts.successEdit)
|
||||
} catch (error) {
|
||||
toast.error(t.profile.toasts.error)
|
||||
console.error("Failed to update profile", error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePictureUpload = async () => {
|
||||
if (!selectedFile) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const response = await updateProfilePicture(selectedFile)
|
||||
setUser(prev => prev ? { ...prev, profile_picture: response.profile_picture } : response)
|
||||
setIsPicModalOpen(false)
|
||||
setSelectedFile(null)
|
||||
toast.success(t.profile.toasts.successImage)
|
||||
} catch (error) {
|
||||
toast.error(t.profile.toasts.error)
|
||||
console.error("Failed to upload picture", error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePicture = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const response = await removeProfilePicture()
|
||||
setUser(prev => prev ? { ...prev, profile_picture: response.profile_picture || null } : response)
|
||||
setIsPicModalOpen(false)
|
||||
toast.success(t.profile.toasts.successRemoveImage)
|
||||
} catch (error) {
|
||||
toast.error(t.profile.toasts.error)
|
||||
console.error("Failed to delete picture", error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag & Drop Handlers
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
setSelectedFile(e.dataTransfer.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-[calc(100vh-73px)] items-center justify-center bg-slate-50 dark:bg-slate-950 transition-colors">
|
||||
<div className="text-slate-500 dark:text-slate-400">Loading...</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-950 p-6 transition-colors">
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
|
||||
{/* Header Card */}
|
||||
<div className="flex flex-col md:flex-row items-center gap-6 rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-6 shadow-sm">
|
||||
|
||||
<div className="relative group cursor-pointer" onClick={() => setIsPicModalOpen(true)}>
|
||||
<div className="h-24 w-24 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800 flex items-center justify-center border-4 border-white dark:border-slate-900 shadow-sm">
|
||||
{user.profile_picture ? (
|
||||
<img src={user.profile_picture} alt="Profile" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<UserIcon className="h-10 w-10 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Camera className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-center md:text-start">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{user.full_name || "-"}
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{user.email || "-"}</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleEditClick} className="flex items-center gap-2">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
{t.profile?.editInfo || 'Edit Profile'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Details Card */}
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-6 shadow-sm">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-white mb-6 border-b border-slate-100 dark:border-slate-800 pb-4">
|
||||
{t.profile?.title || 'Personal Information'}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.firstName || 'First Name'}</span>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{user.first_name || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.lastName || 'Last Name'}</span>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{user.last_name || "-"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.mobileNumber || 'Mobile'}</span>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100 flex items-center">
|
||||
{toPersianNum(user.mobile)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.birthDate || 'Birth Date'}</span>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{formatDate(user.birth_date)} {user.age ? `(${toPersianNum(user.age)} ${t.profile?.yearsOld})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.description || 'Description'}</span>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100 whitespace-pre-wrap">{user.description || "-"}</p>
|
||||
</div>
|
||||
{user.created_at && (
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.profile?.dateJoined || 'Date Joined'}</span>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{formatDate(user.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Edit Profile Modal */}
|
||||
{isEditModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm px-4"
|
||||
onClick={() => !isSaving && setIsEditModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-xl bg-white p-6 shadow-lg dark:bg-slate-900 border dark:border-slate-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">
|
||||
{t.profile?.editInfo || 'Edit Profile'}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium dark:text-slate-300">{t.profile?.firstName || 'First Name'}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.first_name || ""}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium dark:text-slate-300">{t.profile?.lastName || 'Last Name'}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.last_name || ""}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium dark:text-slate-300">{t.profile?.email || 'Email'}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email || ""}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white flex-row-reverse"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<JalaliDatePicker
|
||||
label={t.profile?.birthDate || 'Birth Date'}
|
||||
value={formData.birth_date}
|
||||
onChange={(date) => setFormData({ ...formData, birth_date: date })}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium dark:text-slate-300">{t.profile?.description || 'Description'}</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)} disabled={isSaving}>
|
||||
{t.profile?.cancel || t.cancel || 'Cancel'}
|
||||
</Button>
|
||||
<Button onClick={handleSaveProfile} disabled={isSaving}>
|
||||
{isSaving ? '...' : (t.profile?.save || 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Picture Modal */}
|
||||
{isPicModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm px-4"
|
||||
onClick={() => !isSaving && setIsPicModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-xl bg-white p-6 shadow-lg dark:bg-slate-900 border dark:border-slate-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">
|
||||
{t.profile?.changePicture || 'Profile Picture'}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Drag and Drop Zone */}
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`relative flex flex-col items-center justify-center w-full h-48 border-2 border-dashed rounded-xl cursor-pointer transition-colors ${
|
||||
dragActive
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
|
||||
: "border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-800/80"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="absolute inset-0 p-2">
|
||||
<img
|
||||
src={URL.createObjectURL(selectedFile)}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-contain rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center text-center p-4">
|
||||
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{ t.profile?.imageInput }
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
SVG, PNG, JPG (MAX. 800x400px)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<Button onClick={handlePictureUpload} disabled={!selectedFile || isSaving} className="w-full">
|
||||
{t.profile?.upload || 'Upload'}
|
||||
</Button>
|
||||
|
||||
{user.profile_picture && (
|
||||
<Button variant="destructive" onClick={handleDeletePicture} disabled={isSaving} className="w-full flex items-center justify-center gap-2">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t.profile?.remove || "Remove"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button variant="outline" onClick={() => setIsPicModalOpen(false)} disabled={isSaving}>
|
||||
{t.profile?.cancel || t.cancel || 'Cancel'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
88
src/pages/Terms.tsx
Normal file
88
src/pages/Terms.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
import { Button } from "../components/ui/button"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
|
||||
export default function Terms() {
|
||||
const navigate = useNavigate()
|
||||
const { t, lang } = useTranslation()
|
||||
const isFa = lang === "fa"
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 transition-colors py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 p-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
className={`mb-6 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50 ${isFa ? '-mr-4' : '-ml-4'}`}
|
||||
>
|
||||
{isFa ? <ArrowRight className="ml-2 h-4 w-4" /> : <ArrowLeft className="mr-2 h-4 w-4" />}
|
||||
{t.terms?.back || "Back"}
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
{t.terms?.title}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-8">
|
||||
{t.terms?.lastUpdated}
|
||||
</p>
|
||||
|
||||
<div className="space-y-6 text-slate-700 dark:text-slate-300">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">
|
||||
{t.terms?.sections?.acceptance?.title}
|
||||
</h2>
|
||||
<p>{t.terms?.sections?.acceptance?.content}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">
|
||||
{t.terms?.sections?.license?.title}
|
||||
</h2>
|
||||
<ul className="list-disc px-5 space-y-2">
|
||||
{t.terms?.sections?.license?.items?.map((item: string, index: number) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">
|
||||
{t.terms?.sections?.privacy?.title}
|
||||
</h2>
|
||||
<p className="mb-2">{t.terms?.sections?.privacy?.p1}</p>
|
||||
<ul className="list-disc px-5 space-y-2">
|
||||
<li>
|
||||
<strong className="text-slate-900 dark:text-white font-semibold">
|
||||
{t.terms?.sections?.privacy?.personalLabel}:{" "}
|
||||
</strong>
|
||||
{t.terms?.sections?.privacy?.personalText}
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-slate-900 dark:text-white font-semibold">
|
||||
{t.terms?.sections?.privacy?.usageLabel}:{" "}
|
||||
</strong>
|
||||
{t.terms?.sections?.privacy?.usageText}
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2">{t.terms?.sections?.privacy?.p2}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">
|
||||
{t.terms?.sections?.liability?.title}
|
||||
</h2>
|
||||
<p>{t.terms?.sections?.liability?.content}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-3">
|
||||
{t.terms?.sections?.modifications?.title}
|
||||
</h2>
|
||||
<p>{t.terms?.sections?.modifications?.content}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal 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
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
});
|
||||
Reference in New Issue
Block a user