Compare commits

...

44 Commits

Author SHA1 Message Date
29cadb83e6 feat(timesheet): improve inline edit autosave
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 15:37:40 +03:30
03c7c07a9f fix(datepicker): avoid offscreen calendar placement 2026-06-07 15:37:26 +03:30
666d04ff26 fix(forms): submit modal actions with enter 2026-06-07 15:37:02 +03:30
132c8c44ef fix(modal): add keyboard close and autofocus 2026-06-07 15:36:43 +03:30
8abfcc9c2b feat(about): submit contact form to api 2026-06-07 14:09:54 +03:30
69908887c1 fix(landing): change navbar active link style
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 13:19:57 +03:30
e4ab9d2a12 feat(brand): add qlockify profile images
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 12:50:04 +03:30
b4e06b641d feat(about): add contact section 2026-06-07 12:49:53 +03:30
e8eff6c2cb fix(routing): simplify not found page 2026-06-07 12:49:38 +03:30
a0190bc7ad feat(demo): show sandbox status controls
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 00:51:50 +03:30
c6b1712486 feat(demo): start sandbox from landing 2026-06-07 00:51:35 +03:30
ce6cd6cccc chore(projects): remove unused route pages 2026-06-06 23:43:34 +03:30
3645d60730 refactor(landing): align public navigation 2026-06-06 23:43:22 +03:30
549f6aff86 fix(workspaces): show load error before setup prompt 2026-06-06 23:43:10 +03:30
64b240bf26 refactor(routing): isolate public and protected routes 2026-06-06 23:34:19 +03:30
870d198cc8 feat(about): add static public about page 2026-06-06 23:32:55 +03:30
ef3eaf1206 fix(timezone): fix timer clock-skew
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-26 13:00:35 +03:30
177b20e8ea fix(reports): clarify summary actions and chart data
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-26 12:16:19 +03:30
f30ea5d395 feat(media): manage client and project thumbnails 2026-05-26 12:16:06 +03:30
c895b8f44d feat(reports): sort web breakdown tables
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-25 00:03:52 +03:30
854f439bf9 fix(pagination): remove floating style from pagination component 2026-05-24 21:28:03 +03:30
215425dede feat(projects): improve list filters
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-24 15:22:04 +03:30
22390592eb fix(project-rate): add vertical transition on desktop view sidebar closing
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-24 11:44:02 +03:30
eb41c8528d refactor(auth): replace escaped persian digits
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-24 11:17:55 +03:30
c673159032 feat(projects): expose implicit-access roles in projects and rates modal 2026-05-24 10:31:32 +03:30
9a217fcd54 feat(reports): enrich all-user report details
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-23 20:49:08 +03:30
993dffb51d feat(workspaces): add current user rates panel 2026-05-23 20:44:39 +03:30
35c46ea460 feat(projects): add per-project rate overrides to access modal 2026-05-23 20:29:06 +03:30
065360b7a8 fix(oauth): add callback error page for google oauth flow
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-22 00:59:19 +03:30
dbc0ebb118 fix(workspace): remove redundant buttons in workspace detail page
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-21 19:21:05 +03:30
c69b9d1520 fix(timesheet): soften editable description placeholder 2026-05-21 19:12:10 +03:30
08359041ed fix(timesheet): stop sending client clock for live timers 2026-05-21 13:01:51 +03:30
3d706da457 fix(projects): improve project access modal UI and UX
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-15 12:02:44 +03:30
8584807be1 fix(auth): harden google callback otp step
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-14 23:56:41 +03:30
2ab42c287f feat(auth): verify google signup mobile before account creation
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-14 23:24:21 +03:30
cd5409c9b2 feat(auth): handle google oauth account claim conflicts
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-14 21:15:30 +03:30
38ba89b82f ci(frontend): add gitea actions pipeline
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-14 18:18:26 +03:30
84b7290fe8 feat(frontend): add project access ui and report summaries 2026-05-14 17:06:34 +03:30
eaafb6c3b4 feat(reports): render multi-user chart series 2026-05-13 09:59:23 +03:30
64a949e44f feat(auth): improve otp delivery and verification flow 2026-05-13 09:58:59 +03:30
be0619f5d9 fix(landing): add items-start to title/demo section 2026-05-04 10:17:30 +03:30
a81f8acab7 fix(landing): decrease the size of the hero title 2026-05-03 23:40:03 +03:30
040ee4b1f7 feat(auth): enforce password policy in reset and change flows 2026-05-03 20:02:14 +03:30
de74db2703 fix(sidebar) change Qlockify typo in head title tag and sidebar component 2026-05-03 19:42:40 +03:30
65 changed files with 6203 additions and 1889 deletions

View File

@@ -0,0 +1,80 @@
name: Frontend CI/CD
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: qlockify-node
steps:
- name: Install system dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends git
- name: Checkout repository
env:
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}.git
REPO_SHA: ${{ gitea.sha }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
WORKSPACE: ${{ gitea.workspace }}
run: |
mkdir -p "$WORKSPACE"
cd "$WORKSPACE"
git init
git remote add origin "$REPO_URL"
git -c http.extraHeader="Authorization: Bearer $GITEA_TOKEN" fetch --depth 1 origin "$REPO_SHA"
git checkout --detach FETCH_HEAD
- name: Install dependencies
working-directory: ${{ gitea.workspace }}
run: npm ci
- name: Lint frontend
working-directory: ${{ gitea.workspace }}
run: npm run lint
- name: Build frontend
working-directory: ${{ gitea.workspace }}
run: npm run build
deploy:
if: github.event_name == 'push' && github.ref_name == 'main'
needs:
- build
runs-on: qlockify-deploy
steps:
- name: Install SSH client
run: |
apt-get update
apt-get install -y --no-install-recommends bash openssh-client
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy frontend service
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH }}
BACKEND_BRANCH: ${{ vars.BACKEND_BRANCH }}
FRONTEND_BRANCH: ${{ vars.FRONTEND_BRANCH }}
run: |
ssh -p "${DEPLOY_PORT:-22}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"DEPLOY_ROOT='${DEPLOY_PATH}' DEPLOY_BRANCH='${DEPLOY_BRANCH}' BACKEND_BRANCH='${BACKEND_BRANCH}' FRONTEND_BRANCH='${FRONTEND_BRANCH}' bash '${DEPLOY_PATH}/scripts/deploy.sh' frontend"

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qlockify.ir | Time Tracking for Modern Teams</title> <title>Qlockify</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -5,6 +5,7 @@ import { LanguageProvider } from "./components/LanguageProvider"
import { Toaster } from "./components/ui/toaster" import { Toaster } from "./components/ui/toaster"
import { Navbar } from "./components/Navbar" import { Navbar } from "./components/Navbar"
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { AppProvider } from "./context/AppContext"
import { NotificationsProvider } from "./context/NotificationsContext" import { NotificationsProvider } from "./context/NotificationsContext"
import { WorkspaceProvider } from "./context/WorkspaceContext" import { WorkspaceProvider } from "./context/WorkspaceContext"
import Auth from "./pages/Auth" import Auth from "./pages/Auth"
@@ -17,8 +18,6 @@ import WorkspaceDetail from "./pages/WorkspaceDetail"
import EditWorkspace from "./pages/WorkspaceEdit" import EditWorkspace from "./pages/WorkspaceEdit"
import Clients from "./pages/Clients" import Clients from "./pages/Clients"
import { Projects } from "./pages/Projects" import { Projects } from "./pages/Projects"
import ProjectCreate from "./pages/ProjectCreate"
import ProjectEdit from "./pages/ProjectEdit"
import Tags from "./pages/Tags" import Tags from "./pages/Tags"
import Reports from "./pages/Reports" import Reports from "./pages/Reports"
import Timesheet from "./pages/Timesheet" import Timesheet from "./pages/Timesheet"
@@ -26,7 +25,10 @@ import Logs from "./pages/Logs"
import NotificationsPage from "./pages/Notifications" import NotificationsPage from "./pages/Notifications"
import RateLimitPage from "./pages/RateLimit" import RateLimitPage from "./pages/RateLimit"
import Landing from "./pages/Landing" import Landing from "./pages/Landing"
import About from "./pages/About"
import NotFound from "./pages/NotFound"
import { isRateLimitActive } from "./lib/rateLimit" import { isRateLimitActive } from "./lib/rateLimit"
import { getAccessToken } from "./lib/session"
import { AuthFlowProvider } from "./context/AuthFlowContext" import { AuthFlowProvider } from "./context/AuthFlowContext"
import { LoginMobilePage } from "./pages/auth/LoginMobilePage" import { LoginMobilePage } from "./pages/auth/LoginMobilePage"
import { LoginOtpPage } from "./pages/auth/LoginOtpPage" import { LoginOtpPage } from "./pages/auth/LoginOtpPage"
@@ -64,10 +66,18 @@ const AppRedirect = () => {
return <Navigate to="/rate-limit" replace /> return <Navigate to="/rate-limit" replace />
} }
const isAuthenticated = !!localStorage.getItem("accessToken") const isAuthenticated = !!getAccessToken()
return isAuthenticated ? <Navigate to="/timesheet" replace /> : <Navigate to="/auth" replace /> return isAuthenticated ? <Navigate to="/timesheet" replace /> : <Navigate to="/auth" replace />
} }
const AuthenticatedRedirectGuard = () => {
return getAccessToken() ? <Navigate to="/timesheet" replace /> : <Outlet />
}
const AuthRequiredGuard = () => {
return getAccessToken() ? <Outlet /> : <Navigate to="/auth" replace />
}
const RateLimitGuard = () => { const RateLimitGuard = () => {
const location = useLocation() const location = useLocation()
@@ -78,27 +88,38 @@ const RateLimitGuard = () => {
return <Outlet /> return <Outlet />
} }
const AuthLayout = () => (
<AuthFlowProvider>
<Auth />
</AuthFlowProvider>
)
const ProtectedAppLayout = () => (
<AppProvider>
<WorkspaceProvider>
<NotificationsProvider>
<MainLayout />
</NotificationsProvider>
</WorkspaceProvider>
</AppProvider>
)
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/", element: <Landing /> },
{ path: "/about", element: <About /> },
{ path: "/terms", element: <Terms /> },
{ path: "/rate-limit", element: <RateLimitPage /> },
{ {
element: <RateLimitGuard />, element: <RateLimitGuard />,
children: [ children: [
{ path: "/app", element: <AppRedirect /> },
{ path: "/auth/google/callback", element: <GoogleAuthCallback /> },
{ {
element: ( path: "/auth",
<WorkspaceProvider> element: <AuthenticatedRedirectGuard />,
<Outlet />
</WorkspaceProvider>
),
children: [ children: [
{ path: "/", element: <Landing /> },
{ path: "/app", element: <AppRedirect /> },
{ path: "/auth/google/callback", element: <GoogleAuthCallback /> },
{ {
path: "/auth", element: <AuthLayout />,
element: (
<AuthFlowProvider>
<Auth />
</AuthFlowProvider>
),
children: [ children: [
{ index: true, element: <Navigate to="/auth/login" replace /> }, { index: true, element: <Navigate to="/auth/login" replace /> },
{ path: "login", element: <LoginMobilePage /> }, { path: "login", element: <LoginMobilePage /> },
@@ -112,10 +133,13 @@ const router = createBrowserRouter([
{ path: "forgot-password/password", element: <ForgotPasswordPasswordPage /> }, { path: "forgot-password/password", element: <ForgotPasswordPasswordPage /> },
], ],
}, },
{ path: "/terms", element: <Terms /> }, ],
{ path: "/rate-limit", element: <RateLimitPage /> }, },
{
element: <AuthRequiredGuard />,
children: [
{ {
element: <MainLayout />, element: <ProtectedAppLayout />,
children: [ children: [
{ path: "/profile", element: <Profile /> }, { path: "/profile", element: <Profile /> },
{ path: "/timesheet", element: <Timesheet /> }, { path: "/timesheet", element: <Timesheet /> },
@@ -129,23 +153,20 @@ const router = createBrowserRouter([
{ path: "/workspaces/:id/edit", element: <EditWorkspace /> }, { path: "/workspaces/:id/edit", element: <EditWorkspace /> },
{ path: "/clients", element: <Clients /> }, { path: "/clients", element: <Clients /> },
{ path: "/projects", element: <Projects /> }, { path: "/projects", element: <Projects /> },
{ path: "/projects/create", element: <ProjectCreate /> },
{ path: "/projects/:id/edit", element: <ProjectEdit /> },
], ],
}, },
], ],
}, },
], ],
}, },
{ path: "*", element: <NotFound /> },
]); ]);
function App() { function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<LanguageProvider> <LanguageProvider>
<NotificationsProvider> <RouterProvider router={router} />
<RouterProvider router={router} />
</NotificationsProvider>
<Toaster /> <Toaster />
</LanguageProvider> </LanguageProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -10,6 +10,7 @@ import {
emitSessionChanged, emitSessionChanged,
getAccessToken, getAccessToken,
getRefreshToken, getRefreshToken,
isDemoSession,
} from "../lib/session" } from "../lib/session"
let refreshRequest: Promise<string | null> | null = null let refreshRequest: Promise<string | null> | null = null
@@ -88,9 +89,10 @@ const normalizeJsonResponse = (response: Response) => {
} }
const clearSessionAndRedirect = () => { const clearSessionAndRedirect = () => {
const redirectTarget = isDemoSession() ? "/" : "/auth"
clearSessionTokens() clearSessionTokens()
if (window.location.pathname !== "/auth") { if (window.location.pathname !== redirectTarget) {
window.location.href = "/auth" window.location.href = redirectTarget
} }
} }
@@ -145,6 +147,7 @@ const shouldAttemptRefresh = (endpoint: string) => {
"/api/users/otp/send/", "/api/users/otp/send/",
"/api/users/otp/login/", "/api/users/otp/login/",
"/api/users/token/refresh/", "/api/users/token/refresh/",
"/api/demo/start/",
].includes(normalizedEndpoint) ].includes(normalizedEndpoint)
} }

View File

@@ -5,6 +5,7 @@ export interface Client {
id: string id: string
name: string name: string
notes?: string notes?: string
thumbnail?: string | null
} }
interface PaginatedResponse<T> { interface PaginatedResponse<T> {
@@ -36,14 +37,36 @@ export const getClients = async (
}); });
}; };
export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => { const buildClientBody = (
const response = await authFetch("/api/clients/", { workspaceId: string | null,
method: "POST", data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean },
body: JSON.stringify({ ) => {
workspace_id: workspaceId, const hasFile = data.thumbnail instanceof File;
...data, const shouldClear = Boolean(data.clear_thumbnail);
}), if (!hasFile && !shouldClear) {
}); return {
body: JSON.stringify({
...(workspaceId ? { workspace_id: workspaceId } : {}),
...data,
}),
};
}
const formData = new FormData();
if (workspaceId) formData.append("workspace_id", workspaceId);
if (data.name !== undefined) formData.append("name", data.name);
if (data.notes !== undefined) formData.append("notes", data.notes);
if (data.thumbnail) formData.append("thumbnail", data.thumbnail);
if (shouldClear) formData.append("clear_thumbnail", "true");
return { body: formData };
};
export const createClient = async (workspaceId: string, data: { name: string; notes: string; thumbnail?: File | null }) => {
const requestBody = buildClientBody(workspaceId, data);
const response = await authFetch("/api/clients/", {
method: "POST",
body: requestBody.body,
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null); const errorData = await response.json().catch(() => null);
@@ -54,11 +77,15 @@ export const createClient = async (workspaceId: string, data: { name: string; no
return payload; return payload;
}; };
export const updateClient = async (id: string, data: { name?: string; notes?: string }) => { export const updateClient = async (
const response = await authFetch(`/api/clients/${id}/`, { id: string,
method: "PATCH", data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean },
body: JSON.stringify(data), ) => {
}); const requestBody = buildClientBody(null, data);
const response = await authFetch(`/api/clients/${id}/`, {
method: "PATCH",
body: requestBody.body,
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null); const errorData = await response.json().catch(() => null);

41
src/api/contact.ts Normal file
View File

@@ -0,0 +1,41 @@
import { buildApiError, buildApiUrl } from "./client"
const normalizeDigits = (value: string) =>
value
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
export interface ContactSubmissionPayload {
first_name: string
last_name: string
email: string
mobile: string
message: string
}
export interface ContactSubmissionResponse extends ContactSubmissionPayload {
id: string
status: string
created_at: string
}
export const submitContactForm = async (
payload: ContactSubmissionPayload,
): Promise<ContactSubmissionResponse> => {
const response = await fetch(buildApiUrl("/api/contact/"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...payload,
mobile: normalizeDigits(payload.mobile),
}),
})
if (!response.ok) {
throw await buildApiError(response)
}
return response.json()
}

17
src/api/demo.ts Normal file
View File

@@ -0,0 +1,17 @@
import { authFetch, buildApiError } from "./client"
export interface DemoStartResponse {
access: string
refresh: string
workspace_id: string
expires_at: string
demo_environment_id: string
}
export const startDemo = async (): Promise<DemoStartResponse> => {
const response = await authFetch("/api/demo/start/", {
method: "POST",
})
if (!response.ok) throw await buildApiError(response)
return response.json()
}

View File

@@ -11,13 +11,22 @@ interface AuditUser {
export interface ProjectClient { export interface ProjectClient {
id: string; id: string;
name: string; name: string;
thumbnail?: string | null;
} }
export interface ProjectAccessRateValue {
id: string;
hourly_rate: string;
currency: string;
effective_from: string | null;
}
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
description: string; description: string;
color: string; color: string;
thumbnail?: string | null;
created_at?: string; created_at?: string;
is_archived: boolean; is_archived: boolean;
is_deleted?: boolean; is_deleted?: boolean;
@@ -25,15 +34,60 @@ export interface Project {
created_by?: AuditUser | null; created_by?: AuditUser | null;
client: ProjectClient | null; client: ProjectClient | null;
} }
export interface ProjectAccessItem {
id: string;
name: string;
description: string;
color: string;
is_archived: boolean;
client: ProjectClient | null;
has_access: boolean;
workspace_rate: ProjectAccessRateValue | null;
project_rate: ProjectAccessRateValue | null;
}
export interface ProjectAccessState {
workspace: { id: string; name: string };
user: { id: string; name: string; mobile: string; role: "owner" | "admin" | "member" | "guest" };
items: ProjectAccessItem[];
}
interface ProjectAccessRateMutationResponse {
removed: boolean;
item: ProjectAccessItem;
}
export interface ProjectPayload { export interface ProjectPayload {
name: string; name: string;
description: string; description: string;
color: string; color: string;
is_archived: boolean; is_archived: boolean;
workspace: string; workspace: string;
client: string | null; client: string | null;
} thumbnail?: File | null;
clear_thumbnail?: boolean;
}
const buildProjectBody = (data: Partial<ProjectPayload> & { workspace?: string; name?: string }) => {
const hasFile = data.thumbnail instanceof File;
const shouldClear = Boolean(data.clear_thumbnail);
if (!hasFile && !shouldClear) {
return { body: JSON.stringify(data) };
}
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value === undefined || value === null || key === "clear_thumbnail") return;
if (key === "thumbnail" && value instanceof File) {
formData.append(key, value);
return;
}
formData.append(key, String(value));
});
if (shouldClear) formData.append("clear_thumbnail", "true");
return { body: formData };
};
export const getProjects = async ( export const getProjects = async (
workspaceId: string, workspaceId: string,
@@ -85,10 +139,11 @@ export const getProject = async (id: string) => {
export const createProject = async ( export const createProject = async (
data: Partial<ProjectPayload> & { workspace: string; name: string } data: Partial<ProjectPayload> & { workspace: string; name: string }
) => { ) => {
const response = await authFetch("/api/projects/", { const requestBody = buildProjectBody(data);
method: "POST", const response = await authFetch("/api/projects/", {
body: JSON.stringify(data), method: "POST",
}); body: requestBody.body,
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null); const errorData = await response.json().catch(() => null);
@@ -103,10 +158,11 @@ export const updateProject = async (
id: string, id: string,
data: Partial<ProjectPayload> data: Partial<ProjectPayload>
) => { ) => {
const response = await authFetch(`/api/projects/${id}/`, { const requestBody = buildProjectBody(data);
method: "PATCH", const response = await authFetch(`/api/projects/${id}/`, {
body: JSON.stringify(data), method: "PATCH",
}); body: requestBody.body,
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null); const errorData = await response.json().catch(() => null);
@@ -132,7 +188,7 @@ export const deleteProject = async (id: string) => {
return response.json().catch(() => ({ success: true })); return response.json().catch(() => ({ success: true }));
}; };
export const toggleArchiveProject = async (id: string) => { export const toggleArchiveProject = async (id: string) => {
const response = await authFetch(`/api/projects/${id}/archive/`, { const response = await authFetch(`/api/projects/${id}/archive/`, {
method: "POST", method: "POST",
}); });
@@ -145,3 +201,66 @@ export const toggleArchiveProject = async (id: string) => {
invalidateApiCache(["projects", "reports"]); invalidateApiCache(["projects", "reports"]);
return payload; return payload;
}; };
export const getProjectAccessState = async (workspaceId: string, userId: string): Promise<ProjectAccessState> => {
const query = new URLSearchParams({ workspace: workspaceId, user: userId });
const response = await authFetch(`/api/projects/access/?${query.toString()}`);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to fetch project access");
}
return response.json();
};
const mutateProjectAccess = async (
path: string,
workspaceId: string,
userId: string,
projectIds: string[],
) => {
const response = await authFetch(path, {
method: "POST",
body: JSON.stringify({
workspace: workspaceId,
user: userId,
project_ids: projectIds,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update project access");
}
invalidateApiCache(["projects", "reports"]);
return response.json();
};
export const grantProjectAccess = async (workspaceId: string, userId: string, projectIds: string[]) =>
mutateProjectAccess("/api/projects/access/grant/", workspaceId, userId, projectIds);
export const revokeProjectAccess = async (workspaceId: string, userId: string, projectIds: string[]) =>
mutateProjectAccess("/api/projects/access/revoke/", workspaceId, userId, projectIds);
export const saveProjectAccessRate = async (
workspaceId: string,
userId: string,
projectId: string,
hourlyRate: string | null,
currency: string,
) => {
const response = await authFetch("/api/projects/access/rate/", {
method: "POST",
body: JSON.stringify({
workspace: workspaceId,
user: userId,
project: projectId,
hourly_rate: hourlyRate,
currency,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to save project user rate");
}
invalidateApiCache(["projects", "reports"]);
return response.json() as Promise<ProjectAccessRateMutationResponse>;
};

View File

@@ -30,6 +30,36 @@ export interface WorkspaceUserRate {
effective_from: string; effective_from: string;
} }
export interface WorkspaceProjectRateView {
project: {
id: string;
name: string;
client: { id: string; name: string } | null;
};
rate: {
id: string;
hourly_rate: string;
currency: string;
price_unit?: PriceUnit | null;
effective_from: string | null;
};
}
export interface MyWorkspaceRatesResponse {
workspace: { id: string; name: string };
workspace_rate: {
id: string;
hourly_rate: string;
currency: string;
price_unit?: PriceUnit | null;
effective_from: string | null;
} | null;
accessible_project_count: number;
project_override_count: number;
workspace_fallback_project_count: number;
project_rates: WorkspaceProjectRateView[];
}
interface PaginatedResponse<T> { interface PaginatedResponse<T> {
count: number; count: number;
next: string | null; next: string | null;
@@ -87,6 +117,15 @@ export const getWorkspaceUserRates = async (workspaceId: string) => {
}; };
}; };
export const getMyWorkspaceRates = async (workspaceId: string) => {
const response = await authFetch(`/api/workspaces/${workspaceId}/my-rates/`);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to load your workspace rates");
}
return response.json() as Promise<MyWorkspaceRatesResponse>;
};
export const createWorkspaceUserRate = async (data: { export const createWorkspaceUserRate = async (data: {
workspace_id: string; workspace_id: string;
user_id: string; user_id: string;

View File

@@ -45,10 +45,15 @@ export interface ReportChartBucket {
total_duration: string; total_duration: string;
} }
export interface ChartReportSeries {
user: { id: string; name: string; mobile: string } | null;
buckets: ReportChartBucket[];
}
export interface ChartReportResponse { export interface ChartReportResponse {
scope: ReportScope; scope: ReportScope;
summary: ReportSummary; summary: ReportSummary;
buckets: ReportChartBucket[]; series: ChartReportSeries[];
} }
export interface DailyReportRow { export interface DailyReportRow {
@@ -75,6 +80,40 @@ export interface BreakdownRow {
income_totals: CurrencyTotal[]; income_totals: CurrencyTotal[];
} }
export interface PercentageRow {
id: string;
name: string;
percentage: string;
}
export interface RatePeriodRow {
amount: string;
currency: string;
from_date: string;
to_date: string | null;
project_name?: string | null;
is_current?: boolean;
}
export interface UserReportSummary {
user: { id: string; name: string; mobile: string };
hourly_rates: CurrencyTotal[];
rate_periods: RatePeriodRow[];
total_seconds: number;
total_duration: string;
billable_seconds: number;
billable_duration: string;
non_billable_seconds: number;
non_billable_duration: string;
income_totals: CurrencyTotal[];
project_percentages: PercentageRow[];
client_percentages: PercentageRow[];
tag_percentages: PercentageRow[];
project_income_percentages: PercentageRow[];
client_income_percentages: PercentageRow[];
tag_income_percentages: PercentageRow[];
}
export interface DayDetailEntry { export interface DayDetailEntry {
id: string; id: string;
description: string; description: string;
@@ -109,6 +148,31 @@ export interface TableReportResponse {
clients: BreakdownRow[]; clients: BreakdownRow[];
projects: BreakdownRow[]; projects: BreakdownRow[];
tags: BreakdownRow[]; tags: BreakdownRow[];
client_percentages?: PercentageRow[];
project_percentages?: PercentageRow[];
tag_percentages?: PercentageRow[];
client_income_percentages?: PercentageRow[];
project_income_percentages?: PercentageRow[];
tag_income_percentages?: PercentageRow[];
user_summary?: UserReportSummary;
user_summaries?: UserReportSummary[];
per_user_reports?: UserScopedTableReport[];
}
export interface UserScopedTableReport {
scope: ReportScope;
summary: ReportSummary;
days: DailyReportRow[];
clients: BreakdownRow[];
projects: BreakdownRow[];
tags: BreakdownRow[];
client_percentages?: PercentageRow[];
project_percentages?: PercentageRow[];
tag_percentages?: PercentageRow[];
client_income_percentages?: PercentageRow[];
project_income_percentages?: PercentageRow[];
tag_income_percentages?: PercentageRow[];
user_summary: UserReportSummary;
} }
export interface ReportExportJob { export interface ReportExportJob {
@@ -175,6 +239,17 @@ export const getDayDetailsReport = async (
}); });
}; };
export const getUserSummaryReport = async (
filters: ReportFilters,
userId: string,
): Promise<UserScopedTableReport> => {
const query = `${toQueryString({ ...filters, user: userId })}`;
return cachedGetJson(`/api/reports/user-summary/?${query}`, {
ttlMs: 60 * 1000,
namespaces: ["reports"],
});
};
export const createReportExport = async ( export const createReportExport = async (
filters: ReportFilters, filters: ReportFilters,
exportType: "excel" | "pdf", exportType: "excel" | "pdf",

View File

@@ -23,7 +23,10 @@ export interface TimeEntry {
project_details: TimeEntryProjectDetails | null; project_details: TimeEntryProjectDetails | null;
description: string; description: string;
start_time: string; start_time: string;
start_time_ms: number;
end_time: string | null; end_time: string | null;
end_time_ms: number | null;
server_now_ms: number;
duration: string | null; duration: string | null;
tags: string[]; tags: string[];
tag_details: TimeEntryTagDetails[]; tag_details: TimeEntryTagDetails[];
@@ -56,6 +59,8 @@ interface GroupedTimeEntryResponse {
offset: number; offset: number;
next_offset: number | null; next_offset: number | null;
has_more: boolean; has_more: boolean;
server_now_ms: number;
server_now: string;
groups: TimeEntryGroupWeek[]; groups: TimeEntryGroupWeek[];
} }

View File

@@ -1,29 +1,44 @@
import { authFetch, buildApiError, buildApiUrl } from './client'; import { authFetch, buildApiError, buildApiUrl } from './client';
// --- Auth Endpoints --- const normalizeDigits = (value: string) =>
value
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
// --- Auth Endpoints ---
export const loginWithPassword = async (mobile: string, password: string) => { export const loginWithPassword = async (mobile: string, password: string) => {
const normalizedMobile = normalizeDigits(mobile)
const response = await authFetch('/api/users/login/', { const response = await authFetch('/api/users/login/', {
method: 'POST', method: 'POST',
body: JSON.stringify({ mobile, password }) body: JSON.stringify({ mobile: normalizedMobile, password })
}); });
if (!response.ok) throw await buildApiError(response); if (!response.ok) throw await buildApiError(response);
return response.json(); return response.json();
}; };
export const sendOtp = async (mobile: string, mode: string) => { export interface SendOtpResponse {
detail: string
expires_in_seconds: number
expires_at?: string | null
}
export const sendOtp = async (mobile: string, mode: string): Promise<SendOtpResponse> => {
const normalizedMobile = normalizeDigits(mobile)
const response = await authFetch('/api/users/otp/send/', { const response = await authFetch('/api/users/otp/send/', {
method: 'POST', method: 'POST',
body: JSON.stringify({ mobile, mode }) body: JSON.stringify({ mobile: normalizedMobile, mode })
}); });
if (!response.ok) throw await buildApiError(response); if (!response.ok) throw await buildApiError(response);
return response.json(); return response.json();
}; };
export const loginWithOtp = async (mobile: string, otp: string) => { export const loginWithOtp = async (mobile: string, otp: string) => {
const normalizedMobile = normalizeDigits(mobile)
const normalizedOtp = normalizeDigits(otp)
const response = await authFetch('/api/users/otp/login/', { const response = await authFetch('/api/users/otp/login/', {
method: 'POST', method: 'POST',
body: JSON.stringify({ mobile, code: otp }) body: JSON.stringify({ mobile: normalizedMobile, code: normalizedOtp })
}); });
if (!response.ok) throw await buildApiError(response); if (!response.ok) throw await buildApiError(response);
return response.json(); return response.json();
@@ -37,9 +52,18 @@ export const registerWithOtp = async (
first_name = "", first_name = "",
last_name = "", last_name = "",
) => { ) => {
const normalizedMobile = normalizeDigits(mobile)
const normalizedCode = normalizeDigits(code)
const response = await authFetch("/api/users/register/", { const response = await authFetch("/api/users/register/", {
method: "POST", method: "POST",
body: JSON.stringify({ mobile, code, password, re_password, first_name, last_name }), body: JSON.stringify({
mobile: normalizedMobile,
code: normalizedCode,
password,
re_password,
first_name,
last_name,
}),
}) })
if (!response.ok) throw await buildApiError(response) if (!response.ok) throw await buildApiError(response)
return response.json() return response.json()
@@ -51,9 +75,16 @@ export const resetPasswordWithOtp = async (
password: string, password: string,
re_password: string, re_password: string,
) => { ) => {
const normalizedMobile = normalizeDigits(mobile)
const normalizedCode = normalizeDigits(code)
const response = await authFetch("/api/users/password/reset/", { const response = await authFetch("/api/users/password/reset/", {
method: "POST", method: "POST",
body: JSON.stringify({ mobile, code, password, re_password }), body: JSON.stringify({
mobile: normalizedMobile,
code: normalizedCode,
password,
re_password,
}),
}) })
if (!response.ok) throw await buildApiError(response) if (!response.ok) throw await buildApiError(response)
return response.json() return response.json()
@@ -88,11 +119,16 @@ export type GoogleOAuthFlowResponse =
first_name: string; first_name: string;
last_name: string; last_name: string;
avatar_url: string; avatar_url: string;
resolution: "new_account" | "existing_email_claim";
mobile_hint?: string | null;
} }
| { | {
status: "claim_required"; status: "claim_required";
mobile: string; mobile: string;
detail?: string; detail?: string;
email: string;
resolution: "new_account" | "existing_email_claim" | "existing_mobile_claim";
mobile_hint?: string | null;
}; };
export const getGoogleOAuthFlow = async (flow: string): Promise<GoogleOAuthFlowResponse> => { export const getGoogleOAuthFlow = async (flow: string): Promise<GoogleOAuthFlowResponse> => {
@@ -107,9 +143,10 @@ export const completeGoogleOAuthSignup = async (
flow: string, flow: string,
mobile: string, mobile: string,
): Promise<GoogleOAuthFlowResponse> => { ): Promise<GoogleOAuthFlowResponse> => {
const normalizedMobile = normalizeDigits(mobile)
const response = await authFetch("/api/users/oauth/google/complete/", { const response = await authFetch("/api/users/oauth/google/complete/", {
method: "POST", method: "POST",
body: JSON.stringify({ flow, mobile }), body: JSON.stringify({ flow, mobile: normalizedMobile }),
}); });
if (!response.ok) throw await buildApiError(response); if (!response.ok) throw await buildApiError(response);
return response.json(); return response.json();
@@ -128,9 +165,10 @@ export const verifyGoogleOAuthClaim = async (
flow: string, flow: string,
code: string, code: string,
): Promise<GoogleOAuthFlowResponse> => { ): Promise<GoogleOAuthFlowResponse> => {
const normalizedCode = normalizeDigits(code)
const response = await authFetch("/api/users/oauth/google/claim/verify/", { const response = await authFetch("/api/users/oauth/google/claim/verify/", {
method: "POST", method: "POST",
body: JSON.stringify({ flow, code }), body: JSON.stringify({ flow, code: normalizedCode }),
}); });
if (!response.ok) throw await buildApiError(response); if (!response.ok) throw await buildApiError(response);
return response.json(); return response.json();
@@ -193,9 +231,9 @@ export interface SearchedUser {
profile_picture: string | null; profile_picture: string | null;
} }
export const searchUserByExactMobile = async (mobile: string): Promise<SearchedUser | null> => { export const searchUserByExactMobile = async (mobile: string): Promise<SearchedUser | null> => {
try { try {
const response = await authFetch(`/api/users/search/?mobile=${encodeURIComponent(mobile)}`); const response = await authFetch(`/api/users/search/?mobile=${encodeURIComponent(normalizeDigits(mobile))}`);
if (!response.ok) return null; // Returns null on 404 or other errors if (!response.ok) return null; // Returns null on 404 or other errors
return await response.json(); return await response.json();
} catch (error) { } catch (error) {

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createClient } from "../api/clients"; import { createClient } from "../api/clients";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
@@ -17,18 +17,48 @@ interface CreateClientModalProps {
export default function CreateClientModal({ isOpen, onClose, onSuccess, workspaceId }: CreateClientModalProps) { export default function CreateClientModal({ isOpen, onClose, onSuccess, workspaceId }: CreateClientModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [isLoading, setIsLoading] = useState(false); const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) {
setThumbnailFile(null);
return;
}
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
};
const handleSubmit = async () => { const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
if (!name.trim()) return; event?.preventDefault();
if (!name.trim()) return;
setIsLoading(true); setIsLoading(true);
try { try {
await createClient(workspaceId, { name, notes }); await createClient(workspaceId, { name, notes, thumbnail: thumbnailFile });
toast.success(t.clients.createSuccess); toast.success(t.clients.createSuccess);
onSuccess(); onSuccess();
setName(""); setName("");
setNotes(""); setNotes("");
setThumbnailFile(null);
onClose(); onClose();
} catch (error) { } catch (error) {
console.error(t.clients.errors.createFailed, error); console.error(t.clients.errors.createFailed, error);
@@ -43,16 +73,32 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
<Button variant="outline" onClick={onClose} disabled={isLoading}> <Button variant="outline" onClick={onClose} disabled={isLoading}>
{t.actions?.cancel} {t.actions?.cancel}
</Button> </Button>
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}> <Button type="submit" form="create-client-form" disabled={isLoading || !name.trim()}>
{isLoading ? "..." : t.clients.create} {isLoading ? "..." : t.clients.create}
</Button> </Button>
</> </>
); );
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}> <Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
<div className="space-y-4"> <form id="create-client-form" onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{thumbnailPreview ? <img src={thumbnailPreview} alt="" className="h-full w-full object-cover" /> : name.trim().charAt(0).toUpperCase() || "C"}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{thumbnailFile ? (
<Button type="button" variant="outline" onClick={() => setThumbnailFile(null)}>
{t.remove || "Remove"}
</Button>
) : null}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300"> <label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.clients.clientName} {t.clients.clientName}
</label> </label>
@@ -72,7 +118,7 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
placeholder={t.clients.notesPlaceholder} placeholder={t.clients.notesPlaceholder}
/> />
</div> </div>
</div> </form>
</Modal> </Modal>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, type FormEvent } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type Client } from "../types/client"; import { type Client } from "../types/client";
import { deleteClient } from "../api/clients"; import { deleteClient } from "../api/clients";
@@ -17,8 +17,9 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleDelete = async () => { const handleDelete = async (event?: FormEvent<HTMLFormElement>) => {
if (!client) return; event?.preventDefault();
if (!client) return;
setIsLoading(true); setIsLoading(true);
try { try {
await deleteClient(client.id); await deleteClient(client.id);
@@ -38,11 +39,12 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
<Button variant="outline" onClick={onClose} disabled={isLoading}> <Button variant="outline" onClick={onClose} disabled={isLoading}>
{t.actions?.cancel} {t.actions?.cancel}
</Button> </Button>
<Button <Button
variant="destructive" type="submit"
onClick={handleDelete} form="delete-client-form"
disabled={isLoading} variant="destructive"
> disabled={isLoading}
>
{isLoading ? "..." : t.clients.delete} {isLoading ? "..." : t.clients.delete}
</Button> </Button>
</> </>
@@ -55,10 +57,12 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
title={t.clients.deleteConfirmTitle} title={t.clients.deleteConfirmTitle}
footer={footer} footer={footer}
maxWidth="max-w-sm" maxWidth="max-w-sm"
> >
<p className="text-slate-500 dark:text-slate-400"> <form id="delete-client-form" onSubmit={handleDelete}>
{client ? t.clients.deleteConfirmMessage(client.name) : ""} <p className="text-slate-500 dark:text-slate-400">
</p> {client ? t.clients.deleteConfirmMessage(client.name) : ""}
</Modal> </p>
); </form>
} </Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, type FormEvent } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type Client } from "../types/client"; import { type Client } from "../types/client";
import { updateClient } from "../api/clients"; import { updateClient } from "../api/clients";
@@ -17,22 +17,54 @@ interface EditClientModalProps {
export default function EditClientModal({ isOpen, onClose, onSuccess, client }: EditClientModalProps) { export default function EditClientModal({ isOpen, onClose, onSuccess, client }: EditClientModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [isLoading, setIsLoading] = useState(false); const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [clearThumbnail, setClearThumbnail] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (client) { if (client) {
setName(client.name); setName(client.name);
setNotes(client.notes || ""); setNotes(client.notes || "");
} setThumbnailUrl(client.thumbnail || null);
}, [client]); setThumbnailFile(null);
setClearThumbnail(false);
}
}, [client]);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) return;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
setClearThumbnail(false);
};
const handleSubmit = async () => { const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
if (!client || !name.trim()) return; event?.preventDefault();
if (!client || !name.trim()) return;
setIsLoading(true); setIsLoading(true);
try { try {
await updateClient(client.id, { name, notes }); await updateClient(client.id, { name, notes, thumbnail: thumbnailFile, clear_thumbnail: clearThumbnail });
toast.success(t.clients.updateSuccess); toast.success(t.clients.updateSuccess);
onSuccess(); onSuccess();
onClose(); onClose();
@@ -49,16 +81,45 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
<Button variant="outline" onClick={onClose} disabled={isLoading}> <Button variant="outline" onClick={onClose} disabled={isLoading}>
{t.actions?.cancel} {t.actions?.cancel}
</Button> </Button>
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}> <Button type="submit" form="edit-client-form" disabled={isLoading || !name.trim()}>
{isLoading ? "..." : t.clients.saveChanges} {isLoading ? "..." : t.clients.saveChanges}
</Button> </Button>
</> </>
); );
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}> <Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
<div className="space-y-4"> <form id="edit-client-form" onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{thumbnailPreview ? (
<img src={thumbnailPreview} alt="" className="h-full w-full object-cover" />
) : !clearThumbnail && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
) : (
name.trim().charAt(0).toUpperCase() || "C"
)}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? (
<Button
type="button"
variant="outline"
onClick={() => {
setThumbnailFile(null);
setClearThumbnail(true);
}}
>
{t.remove || "Remove"}
</Button>
) : null}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300"> <label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.clients.clientName} {t.clients.clientName}
</label> </label>
@@ -78,7 +139,7 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
placeholder={t.clients.notesPlaceholder} placeholder={t.clients.notesPlaceholder}
/> />
</div> </div>
</div> </form>
</Modal> </Modal>
); );
} }

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import React, { useEffect, useRef } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Card } from "./ui/card"; import { Card } from "./ui/card";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -23,16 +23,44 @@ export const Modal: React.FC<ModalProps> = ({
footer, footer,
maxWidth = "max-w-lg", maxWidth = "max-w-lg",
}) => { }) => {
useEffect(() => { const cardRef = useRef<HTMLDivElement>(null);
if (isOpen) {
document.body.style.overflow = "hidden"; useEffect(() => {
} else { const handleKeyDown = (event: KeyboardEvent) => {
document.body.style.overflow = "unset"; if (event.key === "Escape") {
} onClose();
return () => { }
document.body.style.overflow = "unset"; };
};
}, [isOpen]); if (isOpen) {
document.body.style.overflow = "hidden";
document.addEventListener("keydown", handleKeyDown);
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen) return;
const focusTimer = window.setTimeout(() => {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement && cardRef.current?.contains(activeElement)) {
return;
}
const firstTextInput = cardRef.current?.querySelector<HTMLElement>(
'input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="tel"], input[type="password"], input[type="number"], textarea',
);
firstTextInput?.focus();
}, 0);
return () => window.clearTimeout(focusTimer);
}, [isOpen]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -42,6 +70,7 @@ export const Modal: React.FC<ModalProps> = ({
onClick={onClose} onClick={onClose}
> >
<Card <Card
ref={cardRef}
className={`flex max-h-[calc(100vh-2rem)] w-full flex-col ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`} className={`flex max-h-[calc(100vh-2rem)] w-full flex-col ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >

View File

@@ -3,13 +3,14 @@ import { useNavigate } from "react-router-dom"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { SettingsMenu } from "./SettingsMenu" import { SettingsMenu } from "./SettingsMenu"
import { LogOut, User, Moon, Sun, Globe, Command, Menu } from "lucide-react" import { FlaskConical, LogOut, User, Moon, Sun, Globe, Command, Menu, RefreshCcw } from "lucide-react"
import { useTheme } from "./ThemeProvider" import { useTheme } from "./ThemeProvider"
import { logoutUser, getUserProfile } from "../api/users" import { logoutUser, getUserProfile } from "../api/users"
import { WorkspaceSelector } from "./WorkspaceSelector" import { WorkspaceSelector } from "./WorkspaceSelector"
import { toast } from "sonner" import { toast } from "sonner"
import { NotificationBell } from "./notifications/NotificationBell" import { NotificationBell } from "./notifications/NotificationBell"
import { clearSessionTokens, getAccessToken, getRefreshToken } from "../lib/session" import { clearSessionTokens, getAccessToken, getRefreshToken, setDemoSessionMeta, setSessionTokens } from "../lib/session"
import { startDemo } from "../api/demo"
type NavbarProps = { type NavbarProps = {
onOpenSidebar?: () => void onOpenSidebar?: () => void
@@ -22,6 +23,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
const [showLogoutModal, setShowLogoutModal] = useState(false) const [showLogoutModal, setShowLogoutModal] = useState(false)
const [isDropdownOpen, setIsDropdownOpen] = useState(false) const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [isResettingDemo, setIsResettingDemo] = useState(false)
const [user, setUser] = useState<any>(null) const [user, setUser] = useState<any>(null)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
@@ -30,6 +32,13 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
const isDarkMode = const isDarkMode =
theme === "dark" || theme === "dark" ||
(theme === "system" && document.documentElement.classList.contains("dark")) (theme === "system" && document.documentElement.classList.contains("dark"))
const isDemoUser = Boolean(user?.is_demo)
const demoExpiryLabel = user?.demo_expires_at
? new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(user.demo_expires_at))
: null
useEffect(() => { useEffect(() => {
const handleProfileUpdated = ((e: CustomEvent) => { const handleProfileUpdated = ((e: CustomEvent) => {
@@ -86,6 +95,23 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
} }
} }
const handleResetDemo = async () => {
if (isResettingDemo) return
setIsResettingDemo(true)
try {
const demo = await startDemo()
setSessionTokens(demo.access, demo.refresh)
setDemoSessionMeta(demo.expires_at)
toast.success(t.demo?.reset || "Fresh demo environment is ready.")
window.location.href = "/timesheet"
} catch (error) {
console.error("Demo reset failed:", error)
toast.error(t.demo?.startError || "Could not start the demo environment.")
} finally {
setIsResettingDemo(false)
}
}
const toggleTheme = () => { const toggleTheme = () => {
setTheme(isDarkMode ? "light" : "dark") setTheme(isDarkMode ? "light" : "dark")
} }
@@ -142,6 +168,12 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
{/* Desktop navbar: keep the old controls here */} {/* Desktop navbar: keep the old controls here */}
<div className="hidden items-center gap-4 md:flex"> <div className="hidden items-center gap-4 md:flex">
{user && <WorkspaceSelector />} {user && <WorkspaceSelector />}
{isDemoUser && (
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-200 bg-cyan-50 px-3 py-2 text-xs font-semibold text-cyan-800 dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<FlaskConical className="h-4 w-4" />
<span>{t.demo?.badge || "Demo environment"}</span>
</div>
)}
{user ? ( {user ? (
<> <>
@@ -178,8 +210,24 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
? `${user.first_name || ""} ${user.last_name || ""}`.trim() ? `${user.first_name || ""} ${user.last_name || ""}`.trim()
: user.email} : user.email}
</p> </p>
{isDemoUser && demoExpiryLabel && (
<p className="mt-1 text-xs text-cyan-700 dark:text-cyan-300">
{(t.demo?.expiresAt || "Expires at")}: {demoExpiryLabel}
</p>
)}
</div> </div>
{isDemoUser && (
<button
onClick={handleResetDemo}
disabled={isResettingDemo}
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-cyan-700 transition-colors hover:bg-cyan-50 disabled:cursor-not-allowed disabled:opacity-70 dark:text-cyan-300 dark:hover:bg-cyan-950/40"
>
<RefreshCcw className="h-4 w-4" />
<span>{isResettingDemo ? t.demo?.starting || "Preparing demo..." : t.demo?.resetAction || "Reset demo"}</span>
</button>
)}
<button <button
onClick={() => { onClick={() => {
navigate("/profile") navigate("/profile")
@@ -286,4 +334,4 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
)} )}
</> </>
) )
} }

View File

@@ -4,33 +4,33 @@ import { useTranslation } from '../hooks/useTranslation';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { Select } from './ui/Select'; import { Select } from './ui/Select';
import { Button } from './ui/button'; import { Button } from './ui/button';
interface PaginationProps { interface PaginationProps {
currentPage: number; currentPage: number;
totalCount: number; totalCount: number;
limit: number; limit: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
pageSizeOptions?: number[]; pageSizeOptions?: number[];
} }
export const Pagination: React.FC<PaginationProps> = ({ export const Pagination: React.FC<PaginationProps> = ({
currentPage, currentPage,
totalCount, totalCount,
limit, limit,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
pageSizeOptions = [10, 20, 50], pageSizeOptions = [10, 20, 50],
}) => { }) => {
const { t, lang } = useTranslation(); const { t, lang } = useTranslation();
const isFa = lang === 'fa'; const isFa = lang === 'fa';
const toPersianNum = (num: string | number | undefined | null) => { const toPersianNum = (num: string | number | undefined | null) => {
if (num === null || num === undefined) return num; if (num === null || num === undefined) return num;
if (!isFa) return num; if (!isFa) return num;
return num.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d as any]); return num.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d as any]);
}; };
const totalPages = Math.ceil(totalCount / limit) || 1; const totalPages = Math.ceil(totalCount / limit) || 1;
if (totalCount === 0) return null; if (totalCount === 0) return null;
@@ -63,8 +63,8 @@ export const Pagination: React.FC<PaginationProps> = ({
}, [currentPage, totalPages]); }, [currentPage, totalPages]);
return ( return (
<div className="sticky bottom-0 left-0 right-0 z-10 mt-auto -mx-4 border-t border-slate-200/80 bg-white/90 px-4 py-4 backdrop-blur-xl dark:border-slate-800/80 dark:bg-slate-950/90 sm:mx-0 sm:rounded-3xl sm:border sm:px-5"> <div className="sticky bottom-0 left-0 right-0 z-10 mt-auto -mx-4 border-t border-slate-200/80 bg-white/95 px-4 py-3 shadow-[0_-10px_30px_rgba(15,23,42,0.06)] backdrop-blur-xl dark:border-slate-800/80 dark:bg-slate-950/95 dark:shadow-[0_-10px_30px_rgba(0,0,0,0.24)] md:-mx-6 md:px-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="mx-auto flex w-full max-w-7xl gap-3 flex-row items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-start"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-start">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Select <Select
@@ -78,9 +78,9 @@ export const Pagination: React.FC<PaginationProps> = ({
label: String(toPersianNum(option)), label: String(toPersianNum(option)),
}))} }))}
className="w-24 shrink-0" className="w-24 shrink-0"
buttonClassName="h-10 rounded-2xl border-slate-200 bg-slate-50 font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200" buttonClassName="h-9 rounded-lg border-slate-200 bg-slate-50 font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
/> />
<span className="inline-flex h-10 items-center rounded-2xl border border-slate-200 bg-slate-50 px-4 text-sm font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 sm:hidden"> <span className="inline-flex h-9 items-center rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 sm:hidden">
{toPersianNum(currentPage)} / {toPersianNum(totalPages)} {toPersianNum(currentPage)} / {toPersianNum(totalPages)}
</span> </span>
</div> </div>
@@ -98,7 +98,7 @@ export const Pagination: React.FC<PaginationProps> = ({
type="button" type="button"
onClick={() => onPageChange(pageItem)} onClick={() => onPageChange(pageItem)}
className={cn( className={cn(
'inline-flex h-10 min-w-10 items-center justify-center rounded-2xl border px-3 text-sm font-semibold transition-colors', 'inline-flex h-9 min-w-9 items-center justify-center rounded-lg border px-3 text-sm font-semibold transition-colors',
pageItem === currentPage pageItem === currentPage
? 'border-sky-500 bg-sky-500 text-white shadow-sm' ? 'border-sky-500 bg-sky-500 text-white shadow-sm'
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800', : 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800',
@@ -109,7 +109,7 @@ export const Pagination: React.FC<PaginationProps> = ({
) : ( ) : (
<span <span
key={`${pageItem}-${index}`} key={`${pageItem}-${index}`}
className="inline-flex h-10 min-w-10 items-center justify-center rounded-2xl text-slate-400 dark:text-slate-500" className="inline-flex h-9 min-w-9 items-center justify-center rounded-lg text-slate-400 dark:text-slate-500"
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</span> </span>
@@ -123,7 +123,7 @@ export const Pagination: React.FC<PaginationProps> = ({
size="sm" size="sm"
onClick={() => onPageChange(currentPage - 1)} onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1} disabled={currentPage === 1}
className="h-10 rounded-2xl border-slate-200 bg-slate-50 px-4 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800" className="h-9 rounded-lg border-slate-200 bg-slate-50 px-3 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
> >
<ChevronLeft className="h-4 w-4 rtl:rotate-180" /> <ChevronLeft className="h-4 w-4 rtl:rotate-180" />
{t.pagination?.previous || 'Previous'} {t.pagination?.previous || 'Previous'}
@@ -138,7 +138,7 @@ export const Pagination: React.FC<PaginationProps> = ({
size="sm" size="sm"
onClick={() => onPageChange(currentPage + 1)} onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages} disabled={currentPage >= totalPages}
className="h-10 rounded-2xl border-slate-200 bg-slate-50 px-4 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800" className="h-9 rounded-lg border-slate-200 bg-slate-50 px-3 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
> >
{t.pagination?.next || 'Next'} {t.pagination?.next || 'Next'}
<ChevronRight className="h-4 w-4 rtl:rotate-180" /> <ChevronRight className="h-4 w-4 rtl:rotate-180" />

View File

@@ -17,6 +17,8 @@ import {
Globe, Globe,
LogOut, LogOut,
LogIn, LogIn,
FlaskConical,
RefreshCcw,
} from "lucide-react" } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@@ -27,7 +29,8 @@ import { WorkspaceSelector } from "./WorkspaceSelector"
import { SettingsMenu } from "./SettingsMenu" import { SettingsMenu } from "./SettingsMenu"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { getUserProfile, logoutUser } from "../api/users" import { getUserProfile, logoutUser } from "../api/users"
import { clearSessionTokens, getAccessToken, getRefreshToken } from "../lib/session" import { clearSessionTokens, getAccessToken, getRefreshToken, setDemoSessionMeta, setSessionTokens } from "../lib/session"
import { startDemo } from "../api/demo"
type SidebarProps = { type SidebarProps = {
mobileOpen?: boolean mobileOpen?: boolean
@@ -37,6 +40,7 @@ type SidebarProps = {
export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) => { export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) => {
const [isCollapsed, setIsCollapsed] = useState(false) const [isCollapsed, setIsCollapsed] = useState(false)
const [showLogoutModal, setShowLogoutModal] = useState(false) const [showLogoutModal, setShowLogoutModal] = useState(false)
const [isResettingDemo, setIsResettingDemo] = useState(false)
const [user, setUser] = useState<any>(null) const [user, setUser] = useState<any>(null)
const navigate = useNavigate() const navigate = useNavigate()
@@ -47,6 +51,13 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
const isRtl = lang === "fa" const isRtl = lang === "fa"
const canViewLogs = canWorkspace(activeWorkspace?.my_role, WORKSPACE_LOGS_VIEW) const canViewLogs = canWorkspace(activeWorkspace?.my_role, WORKSPACE_LOGS_VIEW)
const isDemoUser = Boolean(user?.is_demo)
const demoExpiryLabel = user?.demo_expires_at
? new Intl.DateTimeFormat(isRtl ? "fa-IR" : "en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(user.demo_expires_at))
: null
const ToggleIcon = isRtl const ToggleIcon = isRtl
? isCollapsed ? isCollapsed
@@ -101,6 +112,23 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
} }
} }
const handleResetDemo = async () => {
if (isResettingDemo) return
setIsResettingDemo(true)
try {
const demo = await startDemo()
setSessionTokens(demo.access, demo.refresh)
setDemoSessionMeta(demo.expires_at)
toast.success(t.demo?.reset || "Fresh demo environment is ready.")
window.location.href = "/timesheet"
} catch (error) {
console.error("Demo reset failed:", error)
toast.error(t.demo?.startError || "Could not start the demo environment.")
} finally {
setIsResettingDemo(false)
}
}
const toggleLanguage = () => { const toggleLanguage = () => {
const newLang = isRtl ? "en" : "fa" const newLang = isRtl ? "en" : "fa"
@@ -199,6 +227,19 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
<div className="w-full"> <div className="w-full">
<WorkspaceSelector className="w-full" /> <WorkspaceSelector className="w-full" />
</div> </div>
{isDemoUser && (
<div className="mt-3 rounded-2xl border border-cyan-200 bg-cyan-50 p-3 text-xs font-medium text-cyan-800 dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<div className="flex items-center gap-2 font-semibold">
<FlaskConical className="h-4 w-4" />
{t.demo?.badge || "Demo environment"}
</div>
{demoExpiryLabel && (
<div className="mt-1 text-cyan-700 dark:text-cyan-300">
{(t.demo?.expiresAt || "Expires at")}: {demoExpiryLabel}
</div>
)}
</div>
)}
</div> </div>
) )
} }
@@ -256,6 +297,19 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
</span> </span>
</button> </button>
{isDemoUser && (
<button
onClick={handleResetDemo}
disabled={isResettingDemo}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium text-cyan-700 transition-colors hover:bg-cyan-50 disabled:cursor-not-allowed disabled:opacity-70 dark:text-cyan-300 dark:hover:bg-cyan-950/40"
>
<RefreshCcw size={20} className="shrink-0" />
<span className="truncate whitespace-nowrap">
{isResettingDemo ? t.demo?.starting || "Preparing demo..." : t.demo?.resetAction || "Reset demo"}
</span>
</button>
)}
<button <button
onClick={() => setShowLogoutModal(true)} onClick={() => setShowLogoutModal(true)}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 dark:text-red-500 dark:hover:bg-red-950/50" className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 dark:text-red-500 dark:hover:bg-red-950/50"
@@ -300,6 +354,28 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
</div> </div>
<nav className="flex-1 space-y-1 overflow-y-auto overflow-x-hidden p-4"> <nav className="flex-1 space-y-1 overflow-y-auto overflow-x-hidden p-4">
{isDemoUser && !isCollapsed && (
<div className="mb-3 rounded-2xl border border-cyan-200 bg-cyan-50 p-3 text-xs font-medium text-cyan-800 dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<div className="flex items-center gap-2 font-semibold">
<FlaskConical className="h-4 w-4" />
{t.demo?.badge || "Demo environment"}
</div>
{demoExpiryLabel && (
<div className="mt-1 text-cyan-700 dark:text-cyan-300">
{(t.demo?.expiresAt || "Expires at")}: {demoExpiryLabel}
</div>
)}
<button
type="button"
onClick={handleResetDemo}
disabled={isResettingDemo}
className="mt-2 inline-flex items-center gap-2 rounded-full bg-cyan-600 px-3 py-1.5 text-white transition hover:bg-cyan-700 disabled:cursor-not-allowed disabled:opacity-70 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
>
<RefreshCcw className="h-3.5 w-3.5" />
{isResettingDemo ? t.demo?.starting || "Preparing demo..." : t.demo?.resetAction || "Reset demo"}
</button>
</div>
)}
{renderNavItems(false)} {renderNavItems(false)}
</nav> </nav>
</aside> </aside>
@@ -334,7 +410,7 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div className="flex shrink-0 items-center justify-between border-b border-slate-200 px-5 py-5 dark:border-slate-800"> <div className="flex shrink-0 items-center justify-between border-b border-slate-200 px-5 py-5 dark:border-slate-800">
<h2 className="truncate text-lg font-bold text-slate-800 dark:text-white"> <h2 className="truncate text-lg font-bold text-slate-800 dark:text-white">
Qlockify.ir {t.title || "Qlockify"}
</h2> </h2>
<button <button
@@ -402,4 +478,4 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
)} )}
</> </>
) )
} }

View File

@@ -0,0 +1,821 @@
import { useEffect, useMemo, useState } from "react";
import {
Briefcase,
CheckCheck,
CheckCircle2,
CheckSquare,
FolderTree,
Loader2,
Search,
ShieldAlert,
ShieldCheck,
Square,
UserRound,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
getProjectAccessState,
grantProjectAccess,
revokeProjectAccess,
saveProjectAccessRate,
type ProjectAccessItem,
type ProjectAccessRateValue,
} from "../../api/projects";
import { getPriceUnits, type PriceUnit } from "../../api/rates";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../../api/workspaces";
import { useTranslation } from "../../hooks/useTranslation";
import { formatRateDisplay } from "../../lib/money";
import { Modal } from "../Modal";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Select } from "../ui/Select";
type Labels = {
title: string;
description: string;
close: string;
member: string;
projects: string;
loading: string;
noMembers: string;
noProjects: string;
searchPlaceholder: string;
allClients: string;
selectAllVisible: string;
clearSelection: string;
selectClientProjects: string;
grantSelected: string;
revokeSelected: string;
accessGranted: string;
accessRevoked: string;
memberRole: string;
client: string;
noClient: string;
accessOn: string;
accessOff: string;
loadError: string;
saveError: string;
workspaceRate: string;
projectOverride: string;
inheritsWorkspaceRate: string;
noRate: string;
hourlyRatePlaceholder: string;
currencyPlaceholder: string;
removeRate: string;
projectRateSaved: string;
projectRateRemoved: string;
projectRateSaveError: string;
projectRateRemoveError: string;
implicitAccessHint: string;
};
type RateDraft = {
hourlyRate: string;
currency: string;
};
function getMemberName(member: WorkspaceMembership) {
return (
member.user?.name ||
`${member.user?.first_name || ""} ${member.user?.last_name || ""}`.trim() ||
member.user?.mobile ||
member.id
);
}
function getPreferredCurrency(
item: Pick<ProjectAccessItem, "project_rate" | "workspace_rate">,
defaultCurrency: string,
) {
return item.project_rate?.currency || item.workspace_rate?.currency || defaultCurrency;
}
function getDraftFromItem(item: ProjectAccessItem, defaultCurrency: string): RateDraft {
return {
hourlyRate: item.project_rate?.hourly_rate || "",
currency: getPreferredCurrency(item, defaultCurrency),
};
}
function formatRate(rate: ProjectAccessRateValue | null, labels: Labels, lang: "en" | "fa") {
if (!rate) return labels.noRate;
return formatRateDisplay(rate, lang);
}
export function ProjectAccessModal({
isOpen,
onClose,
workspaceId,
labels,
onApplied,
}: {
isOpen: boolean;
onClose: () => void;
workspaceId: string;
labels: Labels;
onApplied: () => void;
}) {
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
const [selectedUserId, setSelectedUserId] = useState("");
const [projectItems, setProjectItems] = useState<ProjectAccessItem[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [memberSearchQuery, setMemberSearchQuery] = useState("");
const [selectedClientId, setSelectedClientId] = useState("");
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [savingRateProjectId, setSavingRateProjectId] = useState<string | null>(null);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
const [rateDrafts, setRateDrafts] = useState<Record<string, RateDraft>>({});
const [selectedUserRole, setSelectedUserRole] = useState<"owner" | "admin" | "member" | "guest" | "">("");
const { lang } = useTranslation();
const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
const defaultCurrency = priceUnits[0]?.code || "USD";
const activeMembers = useMemo(
() => members.filter((member) => member.is_active),
[members],
);
const filteredMembers = useMemo(() => {
const normalizedSearch = memberSearchQuery.trim().toLowerCase();
const baseMembers = !normalizedSearch
? activeMembers
: activeMembers.filter((member) => {
const memberName = getMemberName(member).toLowerCase();
const memberMobile = member.user?.mobile?.toLowerCase() ?? "";
return memberName.includes(normalizedSearch) || memberMobile.includes(normalizedSearch);
});
return [...baseMembers].sort((a, b) => {
if (a.user.id === selectedUserId) return -1;
if (b.user.id === selectedUserId) return 1;
return 0;
});
}, [activeMembers, memberSearchQuery, selectedUserId]);
const canManageExplicitAccess = selectedUserRole === "member" || selectedUserRole === "guest";
const clientOptions = useMemo(() => {
const map = new Map<string, string>();
projectItems.forEach((item) => {
if (item.client) {
map.set(item.client.id, item.client.name);
}
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [projectItems]);
const visibleProjects = useMemo(() => {
const normalizedSearch = searchQuery.trim().toLowerCase();
return projectItems.filter((item) => {
const matchesClient = !selectedClientId || item.client?.id === selectedClientId;
const matchesSearch =
!normalizedSearch ||
item.name.toLowerCase().includes(normalizedSearch) ||
item.client?.name.toLowerCase().includes(normalizedSearch);
return matchesClient && matchesSearch;
});
}, [projectItems, searchQuery, selectedClientId]);
const visibleProjectIds = useMemo(() => visibleProjects.map((item) => item.id), [visibleProjects]);
const selectedVisibleCount = useMemo(
() => selectedProjectIds.filter((id) => visibleProjectIds.includes(id)).length,
[selectedProjectIds, visibleProjectIds],
);
const currencyOptions = useMemo(() => {
if (priceUnits.length) {
return priceUnits.map((unit) => ({
value: unit.code,
label: unit.local_name ? `${unit.local_name} (${unit.code})` : `${unit.code} (${unit.name})`,
}));
}
const fallbackCurrencies = Array.from(
new Set(
projectItems.flatMap((item) => [
item.project_rate?.currency,
item.workspace_rate?.currency,
defaultCurrency,
]).filter(Boolean) as string[],
),
);
return fallbackCurrencies.map((code) => ({ value: code, label: code }));
}, [defaultCurrency, priceUnits, projectItems]);
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
setMemberSearchQuery("");
setSelectedClientId("");
setSelectedProjectIds([]);
setRateDrafts({});
return;
}
const loadDependencies = async () => {
setLoadingMembers(true);
const [membersResult, priceUnitsResult] = await Promise.allSettled([
fetchWorkspaceMemberships({ workspace: workspaceId, limit: 200, offset: 0 }),
getPriceUnits(),
]);
if (membersResult.status === "fulfilled") {
setMembers(membersResult.value.results || []);
} else {
toast.error(labels.loadError);
setMembers([]);
}
if (priceUnitsResult.status === "fulfilled") {
setPriceUnits(priceUnitsResult.value.results || []);
} else {
setPriceUnits([]);
}
setLoadingMembers(false);
};
void loadDependencies();
}, [isOpen, labels.loadError, workspaceId]);
useEffect(() => {
if (!activeMembers.length) {
setSelectedUserId("");
return;
}
if (!activeMembers.some((member) => member.user.id === selectedUserId)) {
setSelectedUserId(activeMembers[0].user.id);
}
}, [activeMembers, selectedUserId]);
useEffect(() => {
if (!isOpen || !selectedUserId) {
setProjectItems([]);
return;
}
const loadAccessState = async () => {
setLoadingProjects(true);
try {
const response = await getProjectAccessState(workspaceId, selectedUserId);
setSelectedUserRole(response.user.role);
setProjectItems(response.items);
setSelectedProjectIds([]);
} catch {
toast.error(labels.loadError);
setSelectedUserRole("");
setProjectItems([]);
} finally {
setLoadingProjects(false);
}
};
void loadAccessState();
}, [isOpen, labels.loadError, selectedUserId, workspaceId]);
useEffect(() => {
if (!projectItems.length) {
setRateDrafts({});
return;
}
const nextDrafts: Record<string, RateDraft> = {};
projectItems.forEach((item) => {
nextDrafts[item.id] = getDraftFromItem(item, defaultCurrency);
});
setRateDrafts(nextDrafts);
}, [defaultCurrency, projectItems]);
const replaceProjectItem = (nextItem: ProjectAccessItem) => {
setProjectItems((current) =>
current.map((item) => (item.id === nextItem.id ? nextItem : item)),
);
};
const syncRateDraftFromItem = (item: ProjectAccessItem) => {
setRateDrafts((current) => ({
...current,
[item.id]: getDraftFromItem(item, defaultCurrency),
}));
};
const toggleProjectSelection = (projectId: string) => {
if (!canManageExplicitAccess) return;
setSelectedProjectIds((current) =>
current.includes(projectId)
? current.filter((id) => id !== projectId)
: [...current, projectId],
);
};
const handleSelectAllVisible = () => {
if (!canManageExplicitAccess) return;
setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds])));
};
const handleSelectClientProjects = () => {
if (!selectedClientId || !canManageExplicitAccess) return;
const clientProjectIds = visibleProjects
.filter((item) => item.client?.id === selectedClientId)
.map((item) => item.id);
setSelectedProjectIds((current) => Array.from(new Set([...current, ...clientProjectIds])));
};
const refreshState = async () => {
if (!selectedUserId) return;
const response = await getProjectAccessState(workspaceId, selectedUserId);
setProjectItems(response.items);
setSelectedProjectIds([]);
onApplied();
};
const handleMutation = async (mode: "grant" | "revoke") => {
if (!selectedUserId || !selectedProjectIds.length) return;
setIsSaving(true);
try {
if (mode === "grant") {
await grantProjectAccess(workspaceId, selectedUserId, selectedProjectIds);
toast.success(labels.accessGranted);
} else {
await revokeProjectAccess(workspaceId, selectedUserId, selectedProjectIds);
toast.success(labels.accessRevoked);
}
await refreshState();
} catch {
toast.error(labels.saveError);
} finally {
setIsSaving(false);
}
};
const handleRateDraftChange = (projectId: string, patch: Partial<RateDraft>) => {
setRateDrafts((current) => ({
...current,
[projectId]: {
hourlyRate: current[projectId]?.hourlyRate || "",
currency: current[projectId]?.currency || defaultCurrency,
...patch,
},
}));
};
const persistProjectRate = async (
item: ProjectAccessItem,
nextDraft?: Partial<RateDraft>,
) => {
if (!selectedUserId || !item.has_access || savingRateProjectId) return;
const draft = {
...(rateDrafts[item.id] || getDraftFromItem(item, defaultCurrency)),
...nextDraft,
};
const trimmedRate = draft.hourlyRate.trim();
const normalizedCurrency = (draft.currency || getPreferredCurrency(item, defaultCurrency)).toUpperCase();
const currentRate = item.project_rate;
if (!trimmedRate) {
if (!currentRate) {
syncRateDraftFromItem(item);
return;
}
setSavingRateProjectId(item.id);
try {
const response = await saveProjectAccessRate(
workspaceId,
selectedUserId,
item.id,
null,
normalizedCurrency,
);
replaceProjectItem(response.item);
syncRateDraftFromItem(response.item);
toast.success(labels.projectRateRemoved);
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.projectRateRemoveError);
syncRateDraftFromItem(item);
} finally {
setSavingRateProjectId(null);
}
return;
}
if (
currentRate?.hourly_rate === trimmedRate &&
currentRate?.currency === normalizedCurrency
) {
return;
}
setSavingRateProjectId(item.id);
try {
const response = await saveProjectAccessRate(
workspaceId,
selectedUserId,
item.id,
trimmedRate,
normalizedCurrency,
);
replaceProjectItem(response.item);
syncRateDraftFromItem(response.item);
toast.success(labels.projectRateSaved);
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.projectRateSaveError);
syncRateDraftFromItem(item);
} finally {
setSavingRateProjectId(null);
}
};
const footer = (
<>
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<div className="inline-flex h-8 min-w-8 items-center justify-center rounded-full bg-slate-200 px-2 text-xs font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{selectedProjectIds.length}
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() => void handleMutation("grant")}
disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving}
className="gap-2"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
{labels.grantSelected}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void handleMutation("revoke")}
disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving}
className="gap-2"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldAlert className="h-4 w-4" />}
{labels.revokeSelected}
</Button>
</>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={labels.title}
maxWidth="max-w-7xl"
footer={footer}
>
<div className="space-y-4">
<p className="max-w-3xl text-sm text-slate-600 dark:text-slate-400">
{labels.description}
</p>
{!canManageExplicitAccess && selectedUserRole ? (
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100">
{labels.implicitAccessHint}
</div>
) : null}
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
<section
dir={isRtl ? "rtl" : "ltr"}
className="flex min-h-[640px] min-w-0 flex-col overflow-hidden rounded-3xl border border-slate-200 bg-slate-50/40 dark:border-slate-800 dark:bg-slate-950/30 lg:col-span-8"
>
<div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
<Briefcase className="h-4 w-4" />
{labels.projects}
</div>
<div className="flex flex-col gap-3 xl:items-center">
<div className="relative min-w-0 flex-1 w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={labels.searchPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-shadow focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white rtl:pl-4 rtl:pr-10"
/>
</div>
<Select
value={selectedClientId}
onChange={setSelectedClientId}
options={[
{ value: "", label: labels.allClients },
...clientOptions.map((client) => ({
value: client.id,
label: client.name,
})),
]}
className="w-full"
buttonClassName="h-11 w-full rounded-xl"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900">
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleSelectAllVisible}
disabled={!canManageExplicitAccess || !visibleProjects.length}
title={labels.selectAllVisible}
aria-label={labels.selectAllVisible}
>
<CheckCheck className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
onClick={() => setSelectedProjectIds([])}
disabled={!selectedProjectIds.length}
title={labels.clearSelection}
aria-label={labels.clearSelection}
>
<X className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleSelectClientProjects}
disabled={!canManageExplicitAccess || !selectedClientId}
title={labels.selectClientProjects}
aria-label={labels.selectClientProjects}
>
<FolderTree className="h-4 w-4" />
</Button>
<div className="ms-auto text-xs font-medium text-slate-500 dark:text-slate-400">
{selectedVisibleCount}/{visibleProjects.length}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{loadingProjects ? (
<div className="flex items-center gap-2 p-4 text-sm text-slate-500 dark:text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
{labels.loading}
</div>
) : visibleProjects.length === 0 ? (
<div className="p-5 text-sm text-slate-500 dark:text-slate-400">
{labels.noProjects}
</div>
) : (
<div className="grid gap-3">
{visibleProjects.map((item) => {
const isChecked = selectedProjectIds.includes(item.id);
const isRateSaving = savingRateProjectId === item.id;
const draft = rateDrafts[item.id] || getDraftFromItem(item, defaultCurrency);
return (
<div
key={item.id}
className={`rounded-2xl border px-4 py-3 transition ${
isChecked
? "border-sky-200 bg-sky-50/60 shadow-sm dark:border-sky-500/30 dark:bg-sky-500/10"
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
}`}
>
<button
type="button"
onClick={() => toggleProjectSelection(item.id)}
className={`flex w-full items-start gap-3 text-start ${!canManageExplicitAccess ? "cursor-default" : ""}`}
>
<div
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
isChecked
? "border-sky-500 bg-sky-500 text-white"
: "border-slate-300 bg-white text-slate-400 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-500"
}`}
aria-hidden="true"
>
{isChecked ? (
<CheckSquare className="h-3.5 w-3.5" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium text-slate-900 dark:text-slate-100">
{item.name}
</span>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ${
item.has_access
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-1 dark:ring-emerald-400/25"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:ring-1 dark:ring-slate-700"
}`}
>
{item.has_access ? labels.accessOn : labels.accessOff}
</span>
</div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{item.client?.name || labels.noClient}
</div>
{item.description ? (
<div className="mt-1 line-clamp-2 text-sm text-slate-500 dark:text-slate-400">
{item.description}
</div>
) : null}
</div>
</button>
<div className="mt-3 border-t border-slate-200 pt-3 dark:border-slate-800">
<div className="grid gap-2 md:grid-cols-2">
<div className="rounded-xl bg-slate-100/70 px-3 py-2 dark:bg-slate-800/70">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
{labels.workspaceRate}
</div>
<div className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-100">
{formatRate(item.workspace_rate, labels, lang)}
</div>
</div>
<div className="rounded-xl bg-slate-100/70 px-3 py-2 dark:bg-slate-800/70">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
{labels.projectOverride}
</div>
<div className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-100">
{item.project_rate
? formatRate(item.project_rate, labels, lang)
: labels.inheritsWorkspaceRate}
</div>
</div>
</div>
<div className="mt-3 flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="grid flex-1 gap-2 sm:grid-cols-[minmax(0,1fr)_200px]">
<Input
value={draft.hourlyRate}
onChange={(event) =>
handleRateDraftChange(item.id, { hourlyRate: event.target.value })
}
onBlur={() => void persistProjectRate(item)}
inputMode="decimal"
placeholder={labels.hourlyRatePlaceholder}
disabled={!item.has_access || isRateSaving}
className="h-10"
/>
<Select
value={draft.currency}
onChange={(value) => {
handleRateDraftChange(item.id, { currency: value });
if (draft.hourlyRate.trim()) {
void persistProjectRate(item, { currency: value });
}
}}
options={currencyOptions}
disabled={!item.has_access || isRateSaving}
className="w-full"
buttonClassName="h-10 w-full rounded-xl"
/>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="icon"
disabled={!item.has_access || !item.project_rate || isRateSaving}
title={labels.removeRate}
aria-label={labels.removeRate}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
handleRateDraftChange(item.id, { hourlyRate: "" });
void persistProjectRate(item, { hourlyRate: "" });
}}
>
{isRateSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <X className="h-4 w-4" />}
</Button>
</div>
</div>
{!item.has_access && item.project_rate ? (
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{labels.projectOverride}: {formatRate(item.project_rate, labels, lang)}
</div>
) : null}
</div>
</div>
);
})}
</div>
)}
</div>
</section>
<aside
dir={isRtl ? "rtl" : "ltr"}
className="flex min-h-[640px] min-w-0 flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900 lg:col-span-4"
>
<div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
<Users className="h-4 w-4" />
{labels.member}
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<Input
value={memberSearchQuery}
onChange={(event) => setMemberSearchQuery(event.target.value)}
placeholder={labels.member}
className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-shadow focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white rtl:pl-4 rtl:pr-10"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2">
{loadingMembers ? (
<div className="flex items-center gap-2 p-4 text-sm text-slate-500 dark:text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
{labels.loading}
</div>
) : filteredMembers.length === 0 ? (
<div className="p-4 text-sm text-slate-500 dark:text-slate-400">
{labels.noMembers}
</div>
) : (
filteredMembers.map((member) => {
const isActive = member.user.id === selectedUserId;
const isImplicitUser = member.role === "owner" || member.role === "admin";
return (
<button
key={member.id}
type="button"
onClick={() => setSelectedUserId(member.user.id)}
className={`flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-start transition ${
isActive
? "bg-sky-50/80 dark:bg-sky-500/10"
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/40"
}`}
>
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
isActive
? "bg-sky-500 text-white"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300"
}`}
>
<UserRound className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate font-medium text-slate-900 dark:text-slate-100">
{getMemberName(member)}
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium capitalize text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{member.role}
</span>
{isImplicitUser ? (
<span className="shrink-0 rounded-full bg-sky-100 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-200">
{labels.accessOn}
</span>
) : null}
</div>
{isActive ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-sky-500 dark:text-sky-300" />
) : null}
</div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{member.user.mobile}
</div>
</div>
</button>
);
})
)}
</div>
</div>
</aside>
</div>
</div>
</Modal>
);
}

View File

@@ -21,13 +21,41 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE); const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [clients, setClients] = useState<any[]>([]); const [clients, setClients] = useState<any[]>([]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
description: "", description: "",
color: "#3B82F6", color: "#3B82F6",
client: "", client: "",
}); });
const [loadingClients, setLoadingClients] = useState(false); const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [loadingClients, setLoadingClients] = useState(false);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) {
setThumbnailFile(null);
return;
}
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
};
useEffect(() => { useEffect(() => {
if (isOpen && activeWorkspace) { if (isOpen && activeWorkspace) {
@@ -49,14 +77,16 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
workspace: activeWorkspace.id, workspace: activeWorkspace.id,
name: formData.name, name: formData.name,
description: formData.description, description: formData.description,
color: formData.color, color: formData.color,
client: formData.client || null, client: formData.client || null,
thumbnail: thumbnailFile,
}); });
toast.success(t.projects?.createSuccess || "Project created successfully."); toast.success(t.projects?.createSuccess || "Project created successfully.");
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject })); window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
onClose(); onClose();
setFormData({ name: "", description: "", color: "#3B82F6", client: "" }); setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
setThumbnailFile(null);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error(t.projects?.createError || "Failed to create project."); toast.error(t.projects?.createError || "Failed to create project.");
@@ -70,7 +100,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600 dark:hover:bg-slate-700"> <button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600 dark:hover:bg-slate-700">
{t.actions?.cancel || "Cancel"} {t.actions?.cancel || "Cancel"}
</button> </button>
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"> <button form="create-project-form" disabled={loading || !formData.name} type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
{loading ? "..." : t.projects?.create} {loading ? "..." : t.projects?.create}
</button> </button>
</> </>
@@ -80,7 +110,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}> <Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
<form onSubmit={handleSubmit} className="space-y-4"> <form id="create-project-form" onSubmit={handleSubmit} className="space-y-4">
{/* ردیف اول: عنوان و انتخاب رنگ */} {/* ردیف اول: عنوان و انتخاب رنگ */}
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
<div className="flex-1"> <div className="flex-1">
@@ -114,7 +144,24 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
</div> </div>
</div> </div>
<div> <div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl text-sm font-semibold text-white" style={{ backgroundColor: formData.color || "#3B82F6" }}>
{thumbnailPreview ? <img src={thumbnailPreview} alt="" className="h-full w-full object-cover" /> : formData.name.trim().charAt(0).toUpperCase() || "P"}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{thumbnailFile ? (
<button type="button" onClick={() => setThumbnailFile(null)} className="rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600">
{t.remove || "Remove"}
</button>
) : null}
</div>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300"> <label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || 'Description'} {t.projects?.descriptionLabel || 'Description'}
</label> </label>

View File

@@ -24,13 +24,17 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
const canArchiveProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_ARCHIVE); const canArchiveProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_ARCHIVE);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [clients, setClients] = useState<any[]>([]); const [clients, setClients] = useState<any[]>([]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
description: "", description: "",
color: "#3B82F6", color: "#3B82F6",
client: "", client: "",
}); });
const [loadingClients, setLoadingClients] = useState(false); const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [clearThumbnail, setClearThumbnail] = useState(false);
const [loadingClients, setLoadingClients] = useState(false);
useEffect(() => { useEffect(() => {
if (isOpen && activeWorkspace) { if (isOpen && activeWorkspace) {
@@ -47,11 +51,38 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
setFormData({ setFormData({
name: project.name || "", name: project.name || "",
description: project.description || "", description: project.description || "",
color: project.color || "#3B82F6", color: project.color || "#3B82F6",
client: project.client ? project.client.id : "", client: project.client ? project.client.id : "",
}); });
} setThumbnailUrl(project.thumbnail || null);
}, [project]); setThumbnailFile(null);
setClearThumbnail(false);
}
}, [project]);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) return;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
setClearThumbnail(false);
};
const handleSubmit = async (e?: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
@@ -64,6 +95,8 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
description: formData.description, description: formData.description,
color: formData.color, color: formData.color,
client: formData.client || null, client: formData.client || null,
thumbnail: thumbnailFile,
clear_thumbnail: clearThumbnail,
}); });
toast.success(t.projects?.updateSuccess || "Project updated successfully."); toast.success(t.projects?.updateSuccess || "Project updated successfully.");
@@ -121,7 +154,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600"> <button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600">
{t.actions?.cancel || "Cancel"} {t.actions?.cancel || "Cancel"}
</button> </button>
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"> <button form="edit-project-form" disabled={loading || !formData.name} type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
{loading ? "..." : t.save || "Save"} {loading ? "..." : t.save || "Save"}
</button> </button>
</div> </div>
@@ -132,7 +165,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}> <Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
<form onSubmit={handleSubmit} className="space-y-4 mb-6"> <form id="edit-project-form" onSubmit={handleSubmit} className="space-y-4 mb-6">
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
<div className="flex-1"> <div className="flex-1">
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300"> <label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
@@ -164,7 +197,37 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
</div> </div>
</div> </div>
<div> <div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl text-sm font-semibold text-white" style={{ backgroundColor: formData.color || "#3B82F6" }}>
{thumbnailPreview ? (
<img src={thumbnailPreview} alt="" className="h-full w-full object-cover" />
) : !clearThumbnail && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
) : (
formData.name.trim().charAt(0).toUpperCase() || "P"
)}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? (
<button
type="button"
onClick={() => {
setThumbnailFile(null);
setClearThumbnail(true);
}}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600"
>
{t.remove || "Remove"}
</button>
) : null}
</div>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300"> <label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || 'Description'} {t.projects?.descriptionLabel || 'Description'}
</label> </label>

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from "react";
import { Banknote, BriefcaseBusiness, FolderKanban, X } from "lucide-react";
import type { MyWorkspaceRatesResponse } from "../../api/rates";
import { useTranslation } from "../../hooks/useTranslation";
import { formatRateDisplay } from "../../lib/money";
import { Button } from "../ui/button";
export function WorkspaceRatesPanel({
open,
data,
isLoading,
onClose,
}: {
open: boolean;
data: MyWorkspaceRatesResponse | null;
isLoading: boolean;
onClose: () => void;
}) {
const { t, lang } = useTranslation();
const [shouldRender, setShouldRender] = useState(open);
const [isVisible, setIsVisible] = useState(open);
const isRtl = lang === "fa";
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let frameId: number | null = null;
if (open) {
setShouldRender(true);
frameId = window.requestAnimationFrame(() => setIsVisible(true));
} else {
setIsVisible(false);
timeoutId = setTimeout(() => setShouldRender(false), 300);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
if (frameId) window.cancelAnimationFrame(frameId);
};
}, [open]);
if (!shouldRender) {
return null;
}
return (
<div
className={`fixed inset-0 z-[80] flex items-end lg:items-stretch ${
isRtl ? "lg:justify-start" : "lg:justify-end"
}`}
>
<button
type="button"
className={`absolute inset-0 cursor-pointer bg-slate-950/40 backdrop-blur-[2px] transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
onClick={onClose}
aria-label="Close rates panel"
/>
<aside
className={`relative z-10 flex max-h-[88vh] w-full flex-col rounded-t-[2rem] bg-white shadow-2xl transition-transform duration-300 ease-out dark:bg-slate-950 lg:h-full lg:max-h-none lg:w-[34rem] lg:rounded-none ${
isRtl ? "lg:border-r lg:border-slate-800" : "lg:border-l lg:border-slate-800"
} ${
isVisible
? "translate-y-0 lg:translate-x-0"
: `translate-y-full lg:translate-y-0 ${!isRtl ? "lg:-translate-x-full" : "lg:translate-x-full"}`
}`}
>
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{t.rates?.myRatesTitle || "My rates"}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t.rates?.myRatesHint || "Project-specific rates override your workspace rate in this workspace."}
</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="rounded-xl">
<X className="h-5 w-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-5">
{isLoading ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
{t.loading || "Loading..."}
</div>
) : !data ? (
<div className="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
{t.rates?.myRatesEmpty || "No rates are available for this workspace yet."}
</div>
) : (
<div className="space-y-5">
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
<Banknote className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold text-slate-900 dark:text-white">
{t.rates?.workspaceRate || "Workspace rate"}
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.rates?.workspaceRateHint || "This is your default rate unless a project-specific rate overrides it."}
</p>
<div className="mt-3 text-lg font-bold text-slate-900 dark:text-white">
{data.workspace_rate
? formatRateDisplay(
{
hourly_rate: data.workspace_rate.hourly_rate,
currency: data.workspace_rate.currency,
price_unit: data.workspace_rate.price_unit,
},
lang,
)
: (t.rates?.noRate || "No rate")}
</div>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.rates?.accessibleProjects || "Accessible projects"}
</div>
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
{data.accessible_project_count}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.rates?.projectOverrides || "Project overrides"}
</div>
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
{data.project_override_count}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.rates?.workspaceFallbackProjects || "Using workspace rate"}
</div>
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
{data.workspace_fallback_project_count}
</div>
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
{t.rates?.projectSectionTitle || "Project user rates"}
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.rates?.projectOverrideHint || "Only projects with custom overrides are listed here. Other accessible projects use your workspace rate."}
</p>
</div>
{data.project_rates.length ? (
<div className="divide-y divide-slate-100 dark:divide-slate-800">
{data.project_rates.map((projectRate) => (
<div key={projectRate.project.id} className="flex items-start gap-4 px-5 py-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-sky-100 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300">
<FolderKanban className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-slate-900 dark:text-white">
{projectRate.project.name}
</p>
{projectRate.project.client ? (
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<BriefcaseBusiness className="h-3 w-3" />
{projectRate.project.client.name}
</span>
) : null}
</div>
<p className="mt-2 text-sm font-semibold text-slate-900 dark:text-white">
{formatRateDisplay(
{
hourly_rate: projectRate.rate.hourly_rate,
currency: projectRate.rate.currency,
price_unit: projectRate.rate.price_unit,
},
lang,
)}
</p>
</div>
</div>
))}
</div>
) : (
<div className="px-5 py-6 text-sm text-slate-500 dark:text-slate-400">
{t.rates?.projectOverrideEmpty || "You do not have any project-specific rate overrides in this workspace."}
</div>
)}
</div>
</div>
)}
</div>
</aside>
</div>
);
}

View File

@@ -2,149 +2,125 @@ import {
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell, Legend,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts"
import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports"; import type { ChartReportResponse, ChartReportSeries, CurrencyTotal, ReportChartBucket } from "../../api/reports"
import { useTranslation } from "../../hooks/useTranslation"; import { useTranslation } from "../../hooks/useTranslation"
const FA_MONTHS = [ const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"
"فروردین", const SERIES_PALETTE = ["#0ea5e9", "#f97316", "#10b981", "#8b5cf6", "#ef4444", "#eab308", "#14b8a6", "#3b82f6"]
"اردیبهشت",
"خرداد",
"تیر",
"مرداد",
"شهریور",
"مهر",
"آبان",
"آذر",
"دی",
"بهمن",
"اسفند",
];
const normalizeDigits = (value: string) => type ChartRow = {
value bucket_key: string
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit))) bucket_label: string
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit))); tooltip_label: string
[key: string]: string | number
}
const toPersianDigits = (value: string) => const localizeDigits = (value: string, lang: "en" | "fa") =>
value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit); lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value); const shouldTrimCurrencyDecimals = (currency?: string | null) => {
const normalized = (currency || "").toUpperCase()
return normalized === "IRR" || normalized === "IRT"
}
const formatAmount = (value: string, lang: "en" | "fa") => { const formatAmount = (value: string, lang: "en" | "fa", currency?: string | null) => {
const trimmed = value.trim(); const numeric = Number(value.replace(/,/g, ""))
if (!trimmed) return trimmed; if (Number.isNaN(numeric)) {
const numeric = Number(trimmed.replace(/,/g, "")); return localizeDigits(value, lang)
if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang); }
const [integerPart, fractionalPart] = trimmed.replace(/,/g, "").split("."); const [integerPart, fractionalPart] = value.replace(/,/g, "").split(".")
const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US"); const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US")
const signed = trimmed.startsWith("-") ? `-${grouped}` : grouped; const signed = value.startsWith("-") ? `-${grouped}` : grouped
const normalized = fractionalPart ? `${signed}.${fractionalPart}` : signed; const normalizedFraction =
return localizeDigits(normalized, lang); fractionalPart && !shouldTrimCurrencyDecimals(currency) ? fractionalPart.replace(/0+$/, "") : ""
}; const formatted = normalizedFraction ? `${signed}.${normalizedFraction}` : signed
return localizeDigits(formatted, lang)
}
const currencyLabel = (currency: string, lang: "en" | "fa") => { const currencyLabel = (currency: string, lang: "en" | "fa") => {
const normalized = currency.toUpperCase(); if (lang !== "fa") {
if (lang !== "fa") return normalized; return currency.toUpperCase()
}
return ( return (
{ {
USD: "دلار آمریکا", USD: "دلار",
EUR: "یورو", EUR: "یورو",
GBP: "پوند", GBP: "پوند",
IRR: "ریال", IRR: "ریال",
IRT: "تومان", IRT: "تومان",
AED: "درهم", AED: "درهم",
TRY: "لیر", TRY: "لیر",
}[normalized] || normalized }[currency.toUpperCase()] || currency.toUpperCase()
); )
}; }
const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => { const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
if (!totals.length) return "-"; if (!totals.length) {
return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | "); return localizeDigits("0", lang)
}; }
return totals.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`).join(" | ")
}
const formatSecondsTick = (value: number, lang: "en" | "fa") => { const formatSecondsTick = (value: number, lang: "en" | "fa") => {
const hours = value / 3600; const hours = value / 3600
const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1); const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1)
return localizeDigits(rounded, lang); return localizeDigits(rounded, lang)
}; }
const parseIsoDate = (value: string) => { const parseIsoDate = (value: string) => {
const [year, month, day] = value.split("-").map(Number); const [year, month, day] = value.split("-").map(Number)
return new Date(year, (month || 1) - 1, day || 1); return new Date(year, (month || 1) - 1, day || 1)
}; }
const formatIsoDate = (value: Date) => { const formatIsoDate = (value: Date) => {
const year = value.getFullYear(); const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, "0"); const month = String(value.getMonth() + 1).padStart(2, "0")
const day = String(value.getDate()).padStart(2, "0"); const day = String(value.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`
}; }
const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`; const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`
const getPersianDateParts = (value: Date) => { const getCalendarLocale = (lang: "en" | "fa") => (lang === "fa" ? "fa-IR-u-ca-persian" : "en-US")
const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
year: "numeric",
month: "numeric",
day: "numeric",
}).formatToParts(value);
return {
year: Number(normalizeDigits(parts.find((part) => part.type === "year")?.value || "")),
month: Number(normalizeDigits(parts.find((part) => part.type === "month")?.value || "")),
day: Number(normalizeDigits(parts.find((part) => part.type === "day")?.value || "")),
};
};
const getDailyAxisLabel = (date: Date, lang: "en" | "fa", period: string) => { const getDailyAxisLabel = (date: Date, lang: "en" | "fa", period: string) => {
if (period === "this_week") { if (period === "this_week") {
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { return new Intl.DateTimeFormat(getCalendarLocale(lang), { weekday: "short" }).format(date)
weekday: "short",
}).format(date);
} }
if (lang === "fa") { return new Intl.DateTimeFormat(getCalendarLocale(lang), { day: "numeric" }).format(date)
return toPersianDigits(String(getPersianDateParts(date).day)); }
}
return String(date.getDate());
};
const getDailyTooltipLabel = (date: Date, lang: "en" | "fa") => const getDailyTooltipLabel = (date: Date, lang: "en" | "fa") =>
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { new Intl.DateTimeFormat(getCalendarLocale(lang), {
weekday: "long", weekday: "long",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(date); }).format(date)
const getMonthlyAxisLabel = (bucketKey: string, lang: "en" | "fa") => { const getMonthlyAxisLabel = (bucketKey: string, lang: "en" | "fa") => {
if (lang === "fa") { const [year, month] = bucketKey.split("-").map(Number)
const [, month] = bucketKey.split("-").map(Number); return new Intl.DateTimeFormat(getCalendarLocale(lang), { month: "short" }).format(
return FA_MONTHS[(month || 1) - 1] || bucketKey; new Date(year, (month || 1) - 1, 1),
} )
const [year, month] = bucketKey.split("-").map(Number); }
return new Intl.DateTimeFormat("en-US", { month: "short" }).format(new Date(year, (month || 1) - 1, 1));
};
const getMonthlyTooltipLabel = (bucketKey: string, lang: "en" | "fa") => { const getMonthlyTooltipLabel = (bucketKey: string, lang: "en" | "fa") => {
if (lang === "fa") { const [year, month] = bucketKey.split("-").map(Number)
const [year, month] = bucketKey.split("-").map(Number); return new Intl.DateTimeFormat(getCalendarLocale(lang), { month: "long", year: "numeric" }).format(
const monthName = FA_MONTHS[(month || 1) - 1] || bucketKey;
return `${monthName} ${toPersianDigits(String(year))}`;
}
const [year, month] = bucketKey.split("-").map(Number);
return new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(
new Date(year, (month || 1) - 1, 1), new Date(year, (month || 1) - 1, 1),
); )
}; }
const buildDailyBuckets = ( const buildDailyBuckets = (
fromDate: string, fromDate: string,
@@ -153,14 +129,14 @@ const buildDailyBuckets = (
lang: "en" | "fa", lang: "en" | "fa",
period: string, period: string,
) => { ) => {
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]))
const result: ReportChartBucket[] = []; const result: ReportChartBucket[] = []
const cursor = parseIsoDate(fromDate); const cursor = parseIsoDate(fromDate)
const limit = parseIsoDate(toDate); const limit = parseIsoDate(toDate)
while (cursor.getTime() <= limit.getTime()) { while (cursor.getTime() <= limit.getTime()) {
const key = formatIsoDate(cursor); const key = formatIsoDate(cursor)
const existingBucket = map.get(key); const existingBucket = map.get(key)
result.push( result.push(
existingBucket ?? { existingBucket ?? {
bucket_key: key, bucket_key: key,
@@ -168,59 +144,27 @@ const buildDailyBuckets = (
total_seconds: 0, total_seconds: 0,
total_duration: "00:00:00", total_duration: "00:00:00",
}, },
); )
cursor.setDate(cursor.getDate() + 1); cursor.setDate(cursor.getDate() + 1)
} }
return result.map((bucket) => ({ return result.map((bucket) => ({
...bucket, ...bucket,
bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang, period), bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang, period),
})); }))
}; }
const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => { const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]))
const result: ReportChartBucket[] = []
if (lang === "fa") { const start = parseIsoDate(fromDate)
const result: ReportChartBucket[] = []; const end = parseIsoDate(toDate)
const start = getPersianDateParts(parseIsoDate(fromDate)); const cursor = new Date(start.getFullYear(), start.getMonth(), 1)
const end = getPersianDateParts(parseIsoDate(toDate)); const limit = new Date(end.getFullYear(), end.getMonth(), 1)
let year = start.year;
let month = start.month;
while (year < end.year || (year === end.year && month <= end.month)) {
const key = `${year}-${String(month).padStart(2, "0")}`;
const existingBucket = map.get(key);
result.push(
existingBucket ?? {
bucket_key: key,
bucket_label: getMonthlyAxisLabel(key, lang),
total_seconds: 0,
total_duration: "00:00:00",
},
);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return result.map((bucket) => ({
...bucket,
bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang),
}));
}
const result: ReportChartBucket[] = [];
const start = parseIsoDate(fromDate);
const end = parseIsoDate(toDate);
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
const limit = new Date(end.getFullYear(), end.getMonth(), 1);
while (cursor.getTime() <= limit.getTime()) { while (cursor.getTime() <= limit.getTime()) {
const key = monthKeyFromDate(cursor); const key = monthKeyFromDate(cursor)
const existingBucket = map.get(key); const existingBucket = map.get(key)
result.push( result.push(
existingBucket ?? { existingBucket ?? {
bucket_key: key, bucket_key: key,
@@ -228,77 +172,153 @@ const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportC
total_seconds: 0, total_seconds: 0,
total_duration: "00:00:00", total_duration: "00:00:00",
}, },
); )
cursor.setMonth(cursor.getMonth() + 1); cursor.setMonth(cursor.getMonth() + 1)
} }
return result.map((bucket) => ({ return result.map((bucket) => ({
...bucket, ...bucket,
bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang), bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang),
})); }))
}; }
const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => { const buildSeriesBuckets = (
if (!payload) return ""; series: ChartReportSeries,
const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second"; data: ChartReportResponse,
if (useMonth) { lang: "en" | "fa",
return getMonthlyTooltipLabel(payload.bucket_key, lang); useMonthlyBuckets: boolean,
} ) =>
return getDailyTooltipLabel(parseIsoDate(payload.bucket_key), lang); useMonthlyBuckets
}; ? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, series.buckets, lang)
: buildDailyBuckets(data.scope.from_date, data.scope.to_date, series.buckets, lang, data.scope.period)
const createSeriesKey = (series: ChartReportSeries, index: number) => series.user?.id ?? `series_${index}`
const effectiveSeries = (data: ChartReportResponse): ChartReportSeries[] =>
data.series.length
? data.series
: [
{
user: null,
buckets: [],
},
]
const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => {
const useMonthlyBuckets =
data.scope.period === "this_year" ||
data.scope.period === "half_year_first" ||
data.scope.period === "half_year_second"
const seriesList = effectiveSeries(data)
const normalizedSeries = seriesList.map((series) => buildSeriesBuckets(series, data, lang, useMonthlyBuckets))
const baseBuckets = normalizedSeries[0] ?? []
const rows: ChartRow[] = baseBuckets.map((bucket, bucketIndex) => {
const tooltipLabel = useMonthlyBuckets
? getMonthlyTooltipLabel(bucket.bucket_key, lang)
: getDailyTooltipLabel(parseIsoDate(bucket.bucket_key), lang)
const row: ChartRow = {
bucket_key: bucket.bucket_key,
bucket_label: bucket.bucket_label,
tooltip_label: tooltipLabel,
}
seriesList.forEach((series, seriesIndex) => {
const seriesKey = createSeriesKey(series, seriesIndex)
row[seriesKey] = normalizedSeries[seriesIndex]?.[bucketIndex]?.total_seconds ?? 0
})
return row
})
return { rows, seriesList, useMonthlyBuckets }
}
function ChartTooltip({ function ChartTooltip({
active, active,
payload, payload,
label, label,
lang, lang,
totalSecondsLabel, totalHoursLabel,
}: { }: {
active?: boolean; active?: boolean
payload?: ReadonlyArray<{ value?: unknown; payload?: ReportChartBucket }>; payload?: ReadonlyArray<{ color?: string; dataKey?: string | number | ((obj: unknown) => unknown); name?: string | number; value?: unknown }>
label: string; label: string
lang: "en" | "fa"; lang: "en" | "fa"
totalSecondsLabel: string; totalHoursLabel: string
}) { }) {
if (!active || !payload?.length) return null; if (!active || !payload?.length) {
return null
const point = payload[0]; }
const seconds = Number(point.value || 0);
const hours = seconds / 3600;
return ( return (
<div className="rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 shadow-xl shadow-slate-200/60 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/30"> <div className="rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 shadow-xl shadow-slate-200/60 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/30">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400">{label}</div> <div className="text-xs font-semibold text-slate-500 dark:text-slate-400">{label}</div>
<div className="mt-1 text-sm font-semibold text-slate-900 dark:text-white"> <div className="mt-2 space-y-1">
{totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)} {payload.map((item) => {
const seconds = Number(item.value || 0)
const hours = seconds / 3600
return (
<div key={String(item.dataKey)} className="flex items-center gap-2 text-sm text-slate-900 dark:text-white">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: item.color }} />
<span className="font-medium">{item.name}</span>
<span className="text-slate-500 dark:text-slate-400">
{totalHoursLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
</span>
</div>
)
})}
</div> </div>
</div> </div>
); )
} }
export function ReportsChartPanel({ export function ReportsChartPanel({
data, data,
labels, labels,
isLoading,
}: { }: {
data: ChartReportResponse | null; data: ChartReportResponse | null
labels: Record<string, string>; labels: Record<string, string>
isLoading: boolean
}) { }) {
const { lang } = useTranslation(); const { lang } = useTranslation()
if (!data) return null; if (isLoading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{labels.loading}
</div>
<div className="h-8 animate-pulse rounded-xl bg-slate-200/80 dark:bg-slate-800/80" />
</div>
))}
</div>
const useMonthlyBuckets = <div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
data.scope.period === "this_year" || <div className="mb-4 flex items-center justify-between gap-3">
data.scope.period === "half_year_first" || <div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
data.scope.period === "half_year_second"; <div className="text-xs text-slate-500 dark:text-slate-400">{labels.loading}</div>
</div>
<div className="h-[320px] animate-pulse rounded-2xl bg-slate-200/80 dark:bg-slate-800/80 sm:h-[360px]" />
</div>
</div>
)
}
const buckets = useMonthlyBuckets if (!data) {
? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang) return null
: buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang, data.scope.period); }
const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 92 : 44));
const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0;
const { rows, seriesList, useMonthlyBuckets } = buildChartRows(data, lang)
const interval = useMonthlyBuckets ? 0 : rows.length > 20 ? Math.ceil(rows.length / 10) - 1 : 0
const chartMinWidth = Math.max(640, rows.length * (useMonthlyBuckets ? 110 : 52))
const isMultiSeries = seriesList.length > 1
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
@@ -331,15 +351,13 @@ export function ReportsChartPanel({
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5"> <div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div> <div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
<div className="text-xs text-slate-500 dark:text-slate-400"> <div className="text-xs text-slate-500 dark:text-slate-400">{localizeDigits(`${rows.length}`, lang)}</div>
{localizeDigits(`${buckets.length}`, lang)}
</div>
</div> </div>
<div className="overflow-x-auto pb-2"> <div className="overflow-x-auto pb-2">
<div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}> <div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} barCategoryGap={useMonthlyBuckets ? "26%" : "18%"} margin={{ top: 8, right: 18, bottom: 16, left: 10 }}> <BarChart data={rows} barCategoryGap={useMonthlyBuckets ? "26%" : "18%"} margin={{ top: 8, right: 18, bottom: 16, left: 10 }}>
<CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} /> <CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} />
<XAxis <XAxis
dataKey="bucket_label" dataKey="bucket_label"
@@ -360,29 +378,42 @@ export function ReportsChartPanel({
/> />
<Tooltip <Tooltip
cursor={{ fill: "rgba(14,165,233,0.08)" }} cursor={{ fill: "rgba(14,165,233,0.08)" }}
content={({ active, payload }) => ( content={({ active, payload, label }) => (
<ChartTooltip <ChartTooltip
active={active} active={active}
payload={payload} payload={payload}
label={formatTooltipLabel(payload?.[0]?.payload as ReportChartBucket | undefined, lang, data.scope.period)} label={typeof label === "string" ? label : rows[0]?.tooltip_label || ""}
lang={lang} lang={lang}
totalSecondsLabel={labels.totalHours} totalHoursLabel={labels.totalHours}
/> />
)} )}
labelFormatter={(_, payload) => String(payload?.[0]?.payload?.tooltip_label || "")}
/> />
<Bar dataKey="total_seconds" radius={[12, 12, 4, 4]} maxBarSize={useMonthlyBuckets ? 40 : 22}> {isMultiSeries ? (
{buckets.map((bucket) => ( <Legend
<Cell verticalAlign="top"
key={bucket.bucket_key} align="left"
fill={bucket.total_seconds > 0 ? "#0ea5e9" : "#cbd5e1"} wrapperStyle={{ paddingBottom: "16px", fontSize: "12px" }}
/>
) : null}
{seriesList.map((series, index) => {
const dataKey = createSeriesKey(series, index)
return (
<Bar
key={dataKey}
dataKey={dataKey}
name={series.user?.name || labels.totalHours}
fill={SERIES_PALETTE[index % SERIES_PALETTE.length]}
radius={[12, 12, 4, 4]}
maxBarSize={useMonthlyBuckets ? 36 : 22}
/> />
))} )
</Bar> })}
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react" import React, { useEffect, useRef, useState } from "react"
import DatePicker, { DateObject } from "react-multi-date-picker" import DatePicker, { DateObject } from "react-multi-date-picker"
import persian from "react-date-object/calendars/persian" import persian from "react-date-object/calendars/persian"
import persian_fa from "react-date-object/locales/persian_fa" import persian_fa from "react-date-object/locales/persian_fa"
@@ -16,8 +16,10 @@ interface JalaliDatePickerProps {
} }
export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) { export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) {
const isFa = document.documentElement.dir === 'rtl' const isFa = document.documentElement.dir === 'rtl'
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')) const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
const containerRef = useRef<HTMLDivElement>(null)
const [calendarPosition, setCalendarPosition] = useState("bottom-right")
// Listen for dark mode changes dynamically (optional but good for UX) // Listen for dark mode changes dynamically (optional but good for UX)
useEffect(() => { useEffect(() => {
@@ -28,17 +30,28 @@ export default function JalaliDatePicker({ value, onChange, label, disabled, inp
return () => observer.disconnect() return () => observer.disconnect()
}, []) }, [])
const handleChange = (date: DateObject | null) => { const handleChange = (date: DateObject | null) => {
if (!date) { if (!date) {
onChange("") onChange("")
} else { } else {
// Always output standard Gregorian "YYYY-MM-DD" for backend // Always output standard Gregorian "YYYY-MM-DD" for backend
onChange(date.convert(gregorian, gregorian_en).format("YYYY-MM-DD")) onChange(date.convert(gregorian, gregorian_en).format("YYYY-MM-DD"))
} }
} }
return ( const updateCalendarPosition = () => {
<div className="w-full"> const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
const estimatedHeight = 340
const hasMoreSpaceAbove = rect.top > window.innerHeight - rect.bottom
const shouldOpenTop = window.innerHeight - rect.bottom < estimatedHeight && hasMoreSpaceAbove
const horizontal = isFa ? "left" : "right"
setCalendarPosition(`${shouldOpenTop ? "top" : "bottom"}-${horizontal}`)
}
return (
<div ref={containerRef} className="w-full">
{label && ( {label && (
<label className="text-sm font-medium dark:text-slate-300 mb-1 block"> <label className="text-sm font-medium dark:text-slate-300 mb-1 block">
{label} {label}
@@ -52,13 +65,14 @@ export default function JalaliDatePicker({ value, onChange, label, disabled, inp
format="YYYY/MM/DD" format="YYYY/MM/DD"
placeholder={placeholder || "YYYY/MM/DD"} placeholder={placeholder || "YYYY/MM/DD"}
onOpenPickNewDate={false} onOpenPickNewDate={false}
onOpen={updateCalendarPosition}
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 ${inputClassName}`} 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 ${inputClassName}`}
containerClassName="w-full" containerClassName="w-full"
className={isDark ? "bg-dark" : ""} className={isDark ? "bg-dark" : ""}
calendarPosition="bottom-right" calendarPosition={calendarPosition}
fixMainPosition fixMainPosition
disabled={disabled} disabled={disabled}
/> />
</div> </div>
) )
} }

View File

@@ -0,0 +1,185 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Search } from "lucide-react";
import { Input } from "./input";
export interface MultiSearchableSelectOption {
value: string;
label: string;
searchText?: string;
}
interface MultiSearchableSelectProps {
values: string[];
onChange: (values: string[]) => void;
options: MultiSearchableSelectOption[];
placeholder?: string;
searchPlaceholder?: string;
emptyLabel?: string;
disabled?: boolean;
className?: string;
buttonClassName?: string;
renderValue?: (selectedOptions: MultiSearchableSelectOption[]) => string;
}
export function MultiSearchableSelect({
values,
onChange,
options,
placeholder = "",
searchPlaceholder,
emptyLabel,
disabled = false,
className = "",
buttonClassName = "",
renderValue,
}: MultiSearchableSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOptions = useMemo(
() => options.filter((option) => values.includes(option.value)),
[options, values],
);
const filteredOptions = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return options;
return options.filter((option) =>
`${option.label} ${option.searchText || ""}`.toLowerCase().includes(needle),
);
}, [options, query]);
const displayValue = useMemo(() => {
if (!selectedOptions.length) return placeholder;
if (renderValue) return renderValue(selectedOptions);
return selectedOptions.map((option) => option.label).join(", ");
}, [placeholder, renderValue, selectedOptions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setQuery("");
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
useEffect(() => {
if (!isOpen || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const dropdownHeight = 320;
const shouldOpenUp = spaceBelow < dropdownHeight && rect.top > spaceBelow;
setDropdownStyle({
position: "fixed",
top: shouldOpenUp ? `${rect.top - 4}px` : `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
transform: shouldOpenUp ? "translateY(-100%)" : "none",
zIndex: 99999,
});
}, [isOpen]);
useEffect(() => {
const handleScrollOrResize = () => setIsOpen(false);
if (isOpen) {
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("scroll", handleScrollOrResize, true);
}
return () => {
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("scroll", handleScrollOrResize, true);
};
}, [isOpen]);
const toggleValue = (value: string) => {
if (values.includes(value)) {
onChange(values.filter((item) => item !== value));
return;
}
onChange([...values, value]);
};
return (
<div className={`relative ${className}`}>
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={() => !disabled && setIsOpen((current) => !current)}
className={`flex w-full items-center justify-between rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 outline-none transition focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 ${buttonClassName}`}
>
<span className="truncate text-start">{displayValue}</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`} />
</button>
{isOpen &&
createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
>
<div className="border-b border-slate-100 p-2 dark:border-slate-700">
<div className="relative">
<Search className="pointer-events-none absolute inset-y-0 left-3 my-auto h-4 w-4 text-slate-400 rtl:left-auto rtl:right-3" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder || "Search..."}
className="h-9 pl-9 rtl:pl-3 rtl:pr-9"
autoFocus
/>
</div>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filteredOptions.map((option) => {
const isSelected = values.includes(option.value);
return (
<button
key={option.value}
type="button"
onClick={() => toggleValue(option.value)}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition hover:bg-slate-100 dark:hover:bg-slate-700 ${
isSelected
? "bg-blue-50 font-medium text-blue-600 dark:bg-blue-900/30 dark:text-blue-300"
: "text-slate-700 dark:text-slate-300"
}`}
>
<span className="truncate">{option.label}</span>
{isSelected ? <Check className="h-4 w-4 shrink-0" /> : <span className="h-4 w-4 shrink-0" />}
</button>
);
})}
{filteredOptions.length === 0 && (
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">
{emptyLabel || "No results"}
</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}

236
src/content/about.json Normal file
View File

@@ -0,0 +1,236 @@
{
"en": {
"eyebrow": "About Qlockify",
"hero": {
"title": "A focused operating layer for time, work, and accountability.",
"accent": "Qlockify turns daily work into context your team can trust.",
"description": "Qlockify is built for teams that need more than a stopwatch. It connects time entries to workspaces, projects, clients, tags, rates, exports, and activity logs so time becomes useful operational data."
},
"sections": [
{
"title": "Why it exists",
"description": "Most teams lose context between the moment work happens and the moment reports are reviewed. Qlockify keeps that context attached from the first timer click to the final exported report."
},
{
"title": "What it protects",
"description": "The product is designed to reduce missing billable work, unclear project history, manual spreadsheet cleanup, and undocumented workspace changes."
},
{
"title": "How it feels",
"description": "The interface stays minimal because time tracking should not become another heavy workflow. Teams can record work quickly while managers still receive structured, reviewable data."
}
],
"principles": [
{
"title": "Capture first",
"description": "Recording work should be fast enough to happen at the source, with project and billing context attached immediately."
},
{
"title": "Explain every change",
"description": "Workspace roles, rates, access changes, and report actions should remain visible instead of disappearing into chat or memory."
},
{
"title": "Make reports usable",
"description": "Reports should be ready for review, export, and decision-making without rebuilding them in spreadsheets."
}
],
"capabilities": [
{
"title": "Time tracking",
"description": "Run timers, adjust historical entries, and keep work tied to the correct project, client, and tags."
},
{
"title": "Workspace control",
"description": "Manage members, roles, project access, and rates without making everyday users deal with unnecessary complexity."
},
{
"title": "Operational reports",
"description": "Review daily summaries, project distribution, billable work, and exportable reports for management or client review."
},
{
"title": "Activity history",
"description": "Keep a clear trail of important actions so teams can understand what changed and when."
}
],
"audience": [
"Agencies",
"Consulting teams",
"Product teams",
"Operations teams",
"Client-service businesses"
],
"trust": [
"Data is organized around workspaces so each team can keep its own operational context.",
"Project access and user roles help limit what each person can see or use.",
"Exports and logs are designed to make review easier, not to hide important details."
],
"contact": {
"eyebrow": "Contact",
"title": "Need help or want to talk about Qlockify?",
"description": "Send a note or reach out through the support channels below. Contact form submissions are stored securely so the team can review and follow up.",
"formTitle": "Send a note",
"fields": {
"firstName": "First name",
"lastName": "Last name",
"email": "Email",
"mobile": "Mobile",
"message": "Message"
},
"placeholders": {
"firstName": "Your first name",
"lastName": "Your last name",
"email": "you@example.com",
"mobile": "09...",
"message": "Tell us what you need help with..."
},
"submit": "Send message",
"submitting": "Sending...",
"success": "Your message was saved. We will contact you soon.",
"error": "Could not send your message. Please try again.",
"channels": [
{
"label": "Telegram support",
"value": "qlockify_support",
"href": "https://t.me/qlockify_support"
},
{
"label": "Telegram channel",
"value": "qlockify",
"href": "https://t.me/qlockify"
},
{
"label": "Support email",
"value": "qlockify@gmail.com",
"href": "mailto:qlockify@gmail.com"
},
{
"label": "Mobile (message or call)",
"value": "09938228438",
"href": "tel:09938228438"
}
]
},
"cta": {
"title": "Start with one workspace and make time easier to explain.",
"description": "Open Qlockify, track a real workday, and compare the report with the way your team reviews work today.",
"button": "Open Qlockify"
}
},
"fa": {
"eyebrow": "درباره Qlockify",
"hero": {
"title": "یک لایه عملیاتی متمرکز برای زمان، کار و پاسخ‌گویی.",
"accent": "Qlockify کار روزانه را به داده‌ای تبدیل می‌کند که تیم بتواند به آن اعتماد کند.",
"description": "Qlockify برای تیم‌هایی ساخته شده که فقط یک تایمر ساده نمی‌خواهند. این محصول ورودی‌های زمان را به ورک‌اسپیس، پروژه، مشتری، تگ، نرخ، خروجی گزارش و لاگ فعالیت‌ها وصل می‌کند تا زمان به داده عملیاتی قابل استفاده تبدیل شود."
},
"sections": [
{
"title": "چرا ساخته شده است",
"description": "بیشتر تیم‌ها بین لحظه انجام کار و زمان مرور گزارش‌ها، بستر و جزئیات مهم را از دست می‌دهند. Qlockify این بستر را از اولین کلیک تایمر تا گزارش خروجی نهایی نگه می‌دارد."
},
{
"title": "چه چیزی را حفظ می‌کند",
"description": "محصول برای کاهش کارکرد ثبت‌نشده، تاریخچه نامشخص پروژه، پاک‌سازی دستی فایل‌های گزارش و تغییرات ثبت‌نشده در ورک‌اسپیس طراحی شده است."
},
{
"title": "چه حسی دارد",
"description": "رابط کاربری مینیمال می‌ماند، چون ردیابی زمان نباید خودش به یک فرایند سنگین تبدیل شود. تیم می‌تواند سریع کار را ثبت کند و مدیر همچنان داده ساختارمند و قابل بررسی داشته باشد."
}
],
"principles": [
{
"title": "اول ثبت دقیق",
"description": "ثبت کار باید آن‌قدر سریع باشد که در همان لحظه انجام شود و پروژه و بستر مالی همان ابتدا به آن وصل شود."
},
{
"title": "هر تغییر قابل توضیح",
"description": "نقش‌ها، نرخ‌ها، دسترسی پروژه‌ها و عملیات گزارش باید قابل مشاهده بمانند، نه این‌که در چت یا حافظه افراد گم شوند."
},
{
"title": "گزارش قابل استفاده",
"description": "گزارش باید برای بررسی، خروجی گرفتن و تصمیم‌گیری آماده باشد، بدون این‌که دوباره در فایل‌های اکسل ساخته شود."
}
],
"capabilities": [
{
"title": "ردیابی زمان",
"description": "تایمر اجرا کنید، ورودی‌های گذشته را اصلاح کنید و کار را به پروژه، مشتری و تگ درست وصل نگه دارید."
},
{
"title": "کنترل ورک‌اسپیس",
"description": "اعضا، نقش‌ها، دسترسی پروژه‌ها و نرخ‌ها را مدیریت کنید، بدون این‌که کاربران روزمره درگیر پیچیدگی اضافه شوند."
},
{
"title": "گزارش عملیاتی",
"description": "خلاصه روزانه، توزیع پروژه‌ها، کارکرد قابل صورت‌حساب و گزارش‌های خروجی را برای مدیریت یا مرور مشتری بررسی کنید."
},
{
"title": "تاریخچه فعالیت‌ها",
"description": "رد روشنی از عملیات مهم نگه دارید تا تیم بداند چه چیزی، چه زمانی و توسط چه کسی تغییر کرده است."
}
],
"audience": [
"آژانس‌ها",
"تیم‌های مشاوره",
"تیم‌های محصول",
"تیم‌های عملیات",
"کسب‌وکارهای مشتری‌محور"
],
"trust": [
"داده‌ها حول ورک‌اسپیس سازمان‌دهی می‌شوند تا هر تیم بستر عملیاتی خودش را داشته باشد.",
"دسترسی پروژه و نقش کاربران کمک می‌کند هر فرد فقط چیزی را ببیند یا استفاده کند که باید.",
"خروجی‌ها و لاگ‌ها برای ساده‌تر کردن بررسی طراحی شده‌اند، نه برای پنهان کردن جزئیات مهم."
],
"contact": {
"eyebrow": "تماس",
"title": "کمک می‌خواهید یا می‌خواهید درباره Qlockify صحبت کنیم؟",
"description": "یک پیام بفرستید یا از مسیرهای پشتیبانی زیر با ما در ارتباط باشید. پیام‌های فرم تماس ذخیره می‌شوند تا تیم بتواند آن‌ها را بررسی و پیگیری کند.",
"formTitle": "ارسال پیام",
"fields": {
"firstName": "نام",
"lastName": "نام خانوادگی",
"email": "ایمیل",
"mobile": "موبایل",
"message": "پیام"
},
"placeholders": {
"firstName": "نام شما",
"lastName": "نام خانوادگی شما",
"email": "you@example.com",
"mobile": "09...",
"message": "بگویید برای چه چیزی به کمک نیاز دارید..."
},
"submit": "ارسال پیام",
"submitting": "در حال ارسال...",
"success": "پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.",
"error": "ارسال پیام انجام نشد. لطفا دوباره تلاش کنید.",
"channels": [
{
"label": "پشتیبانی تلگرام",
"value": "qlockify_support",
"href": "https://t.me/qlockify_support"
},
{
"label": "کانال تلگرام",
"value": "qlockify",
"href": "https://t.me/qlockify"
},
{
"label": "ایمیل پشتیبانی",
"value": "qlockify@gmail.com",
"href": "mailto:qlockify@gmail.com"
},
{
"label": "موبایل (پیام یا تماس)",
"value": "09938228438",
"href": "tel:09938228438"
}
]
},
"cta": {
"title": "با یک ورک‌اسپیس شروع کنید و توضیح زمان را ساده‌تر کنید.",
"description": "Qlockify را باز کنید، یک روز کاری واقعی را ثبت کنید و گزارش آن را با روش فعلی مرور کار در تیم مقایسه کنید.",
"button": "باز کردن Qlockify"
}
}
}

View File

@@ -8,6 +8,8 @@ interface User {
last_name: string; last_name: string;
email?: string; email?: string;
profile_picture?: string | null; profile_picture?: string | null;
is_demo?: boolean;
demo_expires_at?: string | null;
} }
interface AppContextType { interface AppContextType {

View File

@@ -11,6 +11,8 @@ export type CooldownKey =
interface FlowBranchState { interface FlowBranchState {
mobile: string mobile: string
code: string code: string
otpExpiresAt: number | null
pendingOtpSend: boolean
} }
interface CooldownState { interface CooldownState {
@@ -32,25 +34,34 @@ interface AuthFlowContextValue {
state: AuthFlowState state: AuthFlowState
setMobile: (flow: FlowName, mobile: string) => void setMobile: (flow: FlowName, mobile: string) => void
setCode: (flow: FlowName, code: string) => void setCode: (flow: FlowName, code: string) => void
markOtpSendPending: (flow: FlowName) => void
setOtpDelivery: (flow: FlowName, expiresInSeconds: number) => void
clearOtpDelivery: (flow: FlowName) => void
setCooldown: (key: CooldownKey, seconds: number) => void setCooldown: (key: CooldownKey, seconds: number) => void
clearCooldown: (key: CooldownKey) => void clearCooldown: (key: CooldownKey) => void
resetFlow: (flow: FlowName) => void resetFlow: (flow: FlowName) => void
} }
const STORAGE_KEY = "auth_flow_state:v1" const STORAGE_KEY = "auth_flow_state:v2"
const defaultState: AuthFlowState = { const defaultState: AuthFlowState = {
login: { login: {
mobile: "", mobile: "",
code: "", code: "",
otpExpiresAt: null,
pendingOtpSend: false,
}, },
signup: { signup: {
mobile: "", mobile: "",
code: "", code: "",
otpExpiresAt: null,
pendingOtpSend: false,
}, },
forgotPassword: { forgotPassword: {
mobile: "", mobile: "",
code: "", code: "",
otpExpiresAt: null,
pendingOtpSend: false,
}, },
cooldowns: { cooldowns: {
loginOtpSend: 0, loginOtpSend: 0,
@@ -80,14 +91,20 @@ const parseStoredState = (): AuthFlowState => {
login: { login: {
mobile: parsed.login?.mobile ?? "", mobile: parsed.login?.mobile ?? "",
code: parsed.login?.code ?? "", code: parsed.login?.code ?? "",
otpExpiresAt: parsed.login?.otpExpiresAt ?? null,
pendingOtpSend: parsed.login?.pendingOtpSend ?? false,
}, },
signup: { signup: {
mobile: parsed.signup?.mobile ?? "", mobile: parsed.signup?.mobile ?? "",
code: parsed.signup?.code ?? "", code: parsed.signup?.code ?? "",
otpExpiresAt: parsed.signup?.otpExpiresAt ?? null,
pendingOtpSend: parsed.signup?.pendingOtpSend ?? false,
}, },
forgotPassword: { forgotPassword: {
mobile: parsed.forgotPassword?.mobile ?? "", mobile: parsed.forgotPassword?.mobile ?? "",
code: parsed.forgotPassword?.code ?? "", code: parsed.forgotPassword?.code ?? "",
otpExpiresAt: parsed.forgotPassword?.otpExpiresAt ?? null,
pendingOtpSend: parsed.forgotPassword?.pendingOtpSend ?? false,
}, },
cooldowns: { cooldowns: {
loginOtpSend: parsed.cooldowns?.loginOtpSend ?? 0, loginOtpSend: parsed.cooldowns?.loginOtpSend ?? 0,
@@ -151,6 +168,36 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) {
}, },
})) }))
}, },
markOtpSendPending: (flow) => {
setState((current) => ({
...current,
[flow]: {
...current[flow],
code: "",
pendingOtpSend: true,
},
}))
},
setOtpDelivery: (flow, expiresInSeconds) => {
setState((current) => ({
...current,
[flow]: {
...current[flow],
pendingOtpSend: false,
otpExpiresAt: Date.now() + Math.max(0, expiresInSeconds) * 1000,
},
}))
},
clearOtpDelivery: (flow) => {
setState((current) => ({
...current,
[flow]: {
...current[flow],
pendingOtpSend: false,
otpExpiresAt: null,
},
}))
},
setCooldown: (key, seconds) => { setCooldown: (key, seconds) => {
setState((current) => ({ setState((current) => ({
...current, ...current,
@@ -175,6 +222,8 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) {
[flow]: { [flow]: {
mobile: "", mobile: "",
code: "", code: "",
otpExpiresAt: null,
pendingOtpSend: false,
}, },
})) }))
}, },

View File

@@ -28,26 +28,32 @@ export const useOptionalWorkspace = () => useContext(WorkspaceContext)
export const WorkspaceProvider = ({ children }: { children: ReactNode }) => { export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [workspaces, setWorkspaces] = useState<Workspace[]>([]) const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [activeWorkspace, setActiveWorkspaceState] = useState<Workspace | null>(null) const [activeWorkspace, setActiveWorkspaceState] = useState<Workspace | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [newWorkspaceName, setNewWorkspaceName] = useState("") const [hasLoadedWorkspaces, setHasLoadedWorkspaces] = useState(false)
const [isCreatingFirst, setIsCreatingFirst] = useState(false) const [workspaceLoadError, setWorkspaceLoadError] = useState(false)
const [newWorkspaceName, setNewWorkspaceName] = useState("")
const [isCreatingFirst, setIsCreatingFirst] = useState(false)
const isAuthenticated = !!localStorage.getItem("accessToken") const isAuthenticated = !!localStorage.getItem("accessToken")
const rateLimited = isRateLimitActive() const rateLimited = isRateLimitActive()
const refreshWorkspaces = async () => { const refreshWorkspaces = async () => {
if (!isAuthenticated || isRateLimitActive()) { if (!isAuthenticated || isRateLimitActive()) {
setHasLoadedWorkspaces(false)
setWorkspaceLoadError(false)
setIsLoading(false) setIsLoading(false)
return return
} }
try { try {
setIsLoading(true) setIsLoading(true)
setWorkspaceLoadError(false)
const response = await fetchWorkspaces() const response = await fetchWorkspaces()
const data = Array.isArray(response) ? response : (response?.results || []) const data = Array.isArray(response) ? response : (response?.results || [])
setWorkspaces(data) setWorkspaces(data)
setHasLoadedWorkspaces(true)
if (data.length > 0) { if (data.length > 0) {
const storedId = localStorage.getItem("activeWorkspaceId") const storedId = localStorage.getItem("activeWorkspaceId")
@@ -64,6 +70,8 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
setWorkspaceLoadError(true)
setHasLoadedWorkspaces(false)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -71,6 +79,8 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => { useEffect(() => {
if (!isAuthenticated || rateLimited) { if (!isAuthenticated || rateLimited) {
setHasLoadedWorkspaces(false)
setWorkspaceLoadError(false)
setIsLoading(false) setIsLoading(false)
return return
} }
@@ -90,9 +100,11 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
const addWorkspace = async (name: string) => { const addWorkspace = async (name: string) => {
try { try {
setIsCreatingFirst(true) setIsCreatingFirst(true)
const newWs = await createWorkspace({ name, description: "" }) const newWs = await createWorkspace({ name, description: "" })
setWorkspaces((prev) => [...prev, newWs]) setWorkspaces((prev) => [...prev, newWs])
setActiveWorkspace(newWs) setHasLoadedWorkspaces(true)
setWorkspaceLoadError(false)
setActiveWorkspace(newWs)
toast.success(t.workspace?.successCreate || t.workspace?.toast?.successCreate || "Workspace created!") toast.success(t.workspace?.successCreate || t.workspace?.toast?.successCreate || "Workspace created!")
} catch (error) { } catch (error) {
toast.error(t.workspace?.toast?.errorCreate || "Failed to create workspace") toast.error(t.workspace?.toast?.errorCreate || "Failed to create workspace")
@@ -101,10 +113,31 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
setIsCreatingFirst(false) setIsCreatingFirst(false)
setNewWorkspaceName("") setNewWorkspaceName("")
} }
} }
// Force workspace creation if authenticated but none exist if (!rateLimited && !isLoading && isAuthenticated && workspaceLoadError) {
if (!rateLimited && !isLoading && isAuthenticated && workspaces.length === 0) { return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4">
<div className="w-full max-w-md bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
{t.workspace?.fetchError || "Failed to load workspace data"}
</h2>
<p className="text-slate-600 dark:text-slate-400 mb-6">
{t.workspace?.loadErrorDescription || "The backend service may be unavailable. Please try again in a moment."}
</p>
<Button
onClick={() => void refreshWorkspaces()}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{t.workspace?.retry || "Try again"}
</Button>
</div>
</div>
)
}
// Force workspace creation if authenticated but none exist
if (!rateLimited && !isLoading && isAuthenticated && hasLoadedWorkspaces && workspaces.length === 0) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4"> <div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4">
<div className="w-full max-w-md bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700"> <div className="w-full max-w-md bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700">

76
src/lib/money.ts Normal file
View File

@@ -0,0 +1,76 @@
const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
export const localizeDigits = (value: string, lang: "en" | "fa") =>
lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value;
export const currencyLabel = (currency: string, lang: "en" | "fa") => {
const normalized = currency.toUpperCase();
if (lang !== "fa") return normalized;
return (
{
USD: "دلار آمریکا",
EUR: "یورو",
GBP: "پوند",
IRR: "ریال",
IRT: "تومان",
AED: "درهم",
TRY: "لیر",
}[normalized] || normalized
);
};
export const shouldTrimCurrencyDecimals = (currency?: string | null) => {
const normalized = (currency || "").toUpperCase();
return normalized === "IRR" || normalized === "IRT";
};
export const formatAmountForCurrency = (
value: string,
currency: string | null | undefined,
lang: "en" | "fa",
) => {
const trimmed = value.trim();
if (!trimmed) return trimmed;
const normalizedValue = trimmed.replace(/,/g, "");
const numeric = Number(normalizedValue);
if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang);
const [integerPart, fractionalPart] = normalizedValue.split(".");
const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US");
const signed = normalizedValue.startsWith("-") ? `-${grouped}` : grouped;
let formatted = signed;
if (fractionalPart) {
const nextFraction = shouldTrimCurrencyDecimals(currency)
? ""
: fractionalPart.replace(/0+$/, "");
if (nextFraction) {
formatted = `${formatted}.${nextFraction}`;
}
}
return localizeDigits(formatted, lang);
};
export const formatMoneyTotals = (
totals: { currency: string; amount: string }[],
lang: "en" | "fa",
) => {
if (!totals.length) return "-";
return totals
.map((item) => `${formatAmountForCurrency(item.amount, item.currency, lang)} ${currencyLabel(item.currency, lang)}`)
.join(" | ");
};
export const formatRateDisplay = (
rate: { amount?: string | null; hourly_rate?: string | null; currency: string; price_unit?: { code?: string; local_name?: string; name?: string } | null } | null,
lang: "en" | "fa",
) => {
if (!rate) return "-";
const amount = rate.amount ?? rate.hourly_rate ?? "";
const unitLabel =
lang === "fa"
? rate.price_unit?.local_name || rate.price_unit?.code || currencyLabel(rate.currency, lang)
: rate.price_unit?.code || rate.currency;
return `${formatAmountForCurrency(amount, rate.currency, lang)} ${unitLabel}`;
};

View File

@@ -1,4 +1,5 @@
export const SESSION_CHANGED_EVENT = "auth_session_changed" export const SESSION_CHANGED_EVENT = "auth_session_changed"
const DEMO_EXPIRES_AT_KEY = "demoExpiresAt"
export const getAccessToken = () => localStorage.getItem("accessToken") export const getAccessToken = () => localStorage.getItem("accessToken")
@@ -14,8 +15,21 @@ export const setSessionTokens = (accessToken: string, refreshToken: string) => {
emitSessionChanged() emitSessionChanged()
} }
export const setDemoSessionMeta = (expiresAt: string | null | undefined) => {
if (expiresAt) {
localStorage.setItem(DEMO_EXPIRES_AT_KEY, expiresAt)
} else {
localStorage.removeItem(DEMO_EXPIRES_AT_KEY)
}
}
export const getDemoSessionExpiresAt = () => localStorage.getItem(DEMO_EXPIRES_AT_KEY)
export const isDemoSession = () => !!getDemoSessionExpiresAt()
export const clearSessionTokens = () => { export const clearSessionTokens = () => {
localStorage.removeItem("accessToken") localStorage.removeItem("accessToken")
localStorage.removeItem("refreshToken") localStorage.removeItem("refreshToken")
localStorage.removeItem(DEMO_EXPIRES_AT_KEY)
emitSessionChanged() emitSessionChanged()
} }

View File

@@ -56,11 +56,14 @@ export const en = {
continueToResetPassword: "Continue to reset password", continueToResetPassword: "Continue to reset password",
resetPasswordTitle: "Choose a new password", resetPasswordTitle: "Choose a new password",
resetPasswordDescription: "Set a new password for your account and confirm it.", resetPasswordDescription: "Set a new password for your account and confirm it.",
resetPasswordCta: "Reset password", resetPasswordCta: "Reset password",
newPasswordPlaceholder: "New password", newPasswordPlaceholder: "New password",
confirmPasswordPlaceholder: "Confirm password", confirmPasswordPlaceholder: "Confirm password",
passwordMismatch: "The password confirmation does not match.", passwordMismatch: "The password confirmation does not match.",
enterPassword: "Enter your password", passwordRequirements:
"Password must be at least 8 characters and include at least one lowercase letter, one uppercase letter, one digit, and one symbol.",
passwordReuse: "New password must be different from your previous password.",
enterPassword: "Enter your password",
verifyNumber: "Verify your number", verifyNumber: "Verify your number",
enterMobileDesc: "Enter your mobile number to continue", enterMobileDesc: "Enter your mobile number to continue",
signInDesc: "Sign in using your account password", signInDesc: "Sign in using your account password",
@@ -74,9 +77,14 @@ export const en = {
passwordPlaceholder: "Password", passwordPlaceholder: "Password",
signIn: "Sign In", signIn: "Sign In",
back: "Back", back: "Back",
otpPlaceholder: "5-digit code", otpPlaceholder: "5-digit code",
verifyAndContinue: "Verify & Continue", verifyAndContinue: "Verify & Continue",
terms: "By clicking continue, you agree to our Terms of Service and Privacy Policy.", sendingOtp: "Sending code...",
verifyingOtp: "Verifying code...",
resendOtp: "Resend code",
otpExpiresIn: (time: string) => `Code expires in ${time}`,
otpExpired: "This code has expired. Request a new code to 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.", brandingQuote: "Manage your time and workspaces efficiently with our minimal, fast, and secure platform.",
toasts: { toasts: {
enterMobile: "Please enter your mobile number", enterMobile: "Please enter your mobile number",
@@ -101,26 +109,35 @@ export const en = {
countdownLabel: (time: string) => `Retry in ${time}`, countdownLabel: (time: string) => `Retry in ${time}`,
fallback: "Too many requests. Please wait and try again.", fallback: "Too many requests. Please wait and try again.",
}, },
google: { google: {
loadingTitle: "Completing Google sign in", loadingTitle: "Completing Google sign in",
loadingDescription: "We are validating your Google account and preparing the next step.", loadingDescription: "We are validating your Google account and preparing the next step.",
collectMobileTitle: "Finish your account setup", collectMobileTitle: "Finish your account setup",
collectMobileDescription: (email: string) => collectMobileDescription: (email: string) =>
`Google verified ${email}. Enter your mobile number to finish creating your account.`, `Google verified ${email}. Enter your mobile number to finish creating your account.`,
claimTitle: "Verify your existing account", existingEmailClaimDescription: (email: string, mobileHint: string) =>
claimDescription: (mobile: string) => `Google verified ${email}. Enter the mobile number already connected to this account (${mobileHint}) to confirm ownership.`,
`An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`, claimTitle: "Verify your existing account",
errorTitle: "Google sign in could not be completed", claimDescription: (mobile: string) =>
missingFlow: "The Google sign-in flow is missing or has expired.", `An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`,
loadFailed: "We could not load your Google sign-in state.", mobileClaimDescription: (mobile: string) =>
completeFailed: "We could not finish your Google account setup.", `A mobile-only account with ${mobile} already exists. Verify the code sent to that number to attach Google and keep that account.`,
claimOtpSent: "Verification code sent successfully.", errorTitle: "Google sign in could not be completed",
googleAccount: "Google account", cancelled: "Google sign in was cancelled before it could be completed.",
completeButton: "Continue and create account", missingFlow: "The Google sign-in flow is missing or has expired.",
verifyClaimButton: "Verify and continue", loadFailed: "We could not load your Google sign-in state.",
resendClaimOtp: "Resend verification code", callbackFailed: "We could not complete Google sign in. Please try again.",
restartGoogle: "Start Google sign in again", tokenExchangeFailed: "Google sign in is temporarily unavailable. Please try again in a few minutes.",
}, profileLookupFailed: "We could not load your Google profile. Please try again.",
completeFailed: "We could not finish your Google account setup.",
claimOtpSent: "Verification code sent successfully.",
googleAccount: "Google account",
mobileHintLabel: (mobileHint: string) => `Expected mobile: ${mobileHint}`,
completeButton: "Continue and create account",
verifyClaimButton: "Verify and continue",
resendClaimOtp: "Resend verification code",
restartGoogle: "Start Google sign in again",
},
}, },
loginTerms: { loginTerms: {
@@ -264,9 +281,10 @@ export const en = {
statsRates: "Rates set", statsRates: "Rates set",
statsOwnersAdmins: "Owners & admins", statsOwnersAdmins: "Owners & admins",
statsGuests: "Guests", statsGuests: "Guests",
membersSectionTitle: "Members", membersSectionTitle: "Members",
membersSectionSubtitle: "People in this workspace and their current roles.", membersSectionSubtitle: "People in this workspace and their current roles.",
membersLocked: "Only owners and admins can view the full member list.", projectRateHint: "Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set a custom rate that overrides the workspace rate for that project.",
membersLocked: "Only owners and admins can view the full member list.",
manageMembers: "Manage members", manageMembers: "Manage members",
mobileNumber: "Mobile Number", mobileNumber: "Mobile Number",
youLabel: "You", youLabel: "You",
@@ -282,8 +300,10 @@ export const en = {
}, },
createdSuccess: "Workspace created successfully", createdSuccess: "Workspace created successfully",
updatedSuccess: "Workspace updated successfully", updatedSuccess: "Workspace updated successfully",
fetchError: "Failed to load workspace data", fetchError: "Failed to load workspace data",
remove: "Remove", loadErrorDescription: "The backend service may be unavailable. Please try again in a moment.",
retry: "Try again",
remove: "Remove",
noUsersFound: "No user found", noUsersFound: "No user found",
selectRole: "Select Role", selectRole: "Select Role",
add: "Add", add: "Add",
@@ -376,13 +396,14 @@ export const en = {
collapse: 'Collapse', collapse: 'Collapse',
}, },
landing: { landing: {
brandLabel: "Operating system for time", brandLabel: "Operating system for time",
eyebrow: "Built for high-discipline teams that need clean time intelligence", eyebrow: "Built for high-discipline teams that need clean time intelligence",
nav: { nav: {
demo: "Product demo", demo: "Product demo",
features: "Core capabilities", features: "Core capabilities",
workflow: "How it works", workflow: "How it works",
about: "About us",
}, },
actions: { actions: {
switchToEnglish: "English", switchToEnglish: "English",
@@ -391,8 +412,9 @@ export const en = {
openApp: "Open app", openApp: "Open app",
openWorkspace: "Open workspace", openWorkspace: "Open workspace",
startNow: "Start tracking with control", startNow: "Start tracking with control",
watchDemo: "See the product demo", watchDemo: "See the product demo",
readTerms: "Read terms", readTerms: "Read terms",
readAbout: "About Qlockify",
}, },
hero: { hero: {
titleTop: "Turn every working hour into a reliable operating signal.", titleTop: "Turn every working hour into a reliable operating signal.",
@@ -462,7 +484,16 @@ export const en = {
finalCtaTitle: "If your team sells expertise or ships client work, your time system should look this serious.", finalCtaTitle: "If your team sells expertise or ships client work, your time system should look this serious.",
finalCtaDescription: finalCtaDescription:
"Open the app, create a workspace, and see how fast your reporting discipline improves when the product stops leaking context.", "Open the app, create a workspace, and see how fast your reporting discipline improves when the product stops leaking context.",
}, },
demo: {
badge: "Demo environment",
starting: "Preparing demo...",
started: "Demo environment is ready.",
startError: "Could not start the demo environment.",
expiresAt: "Expires at",
resetAction: "Reset demo",
reset: "Fresh demo environment is ready.",
},
ordering: { ordering: {
createdAtDesc: "Newest First", createdAtDesc: "Newest First",
@@ -508,8 +539,26 @@ export const en = {
filterClients: "Filter by client", filterClients: "Filter by client",
clearClientFilters: "Clear filters", clearClientFilters: "Clear filters",
namePlaceholder: "Project name...", namePlaceholder: "Project name...",
teamMembers: "Team Members", teamMembers: "Team Members",
creator: "Creator", manageAccess: "Projects & Rates",
accessModalTitle: "Projects & Rates",
accessModalDescription: "Manage project access for members and guests, and set project-specific rates for any workspace user.",
accessMemberLabel: "User",
accessNoMembers: "No workspace users were found.",
accessNoProjects: "No projects found.",
accessSelectVisible: "Select all visible",
accessClearSelection: "Clear selection",
accessSelectClientProjects: "Select all projects for client",
accessGrant: "Grant selected",
accessRevoke: "Revoke selected",
accessOn: "Has access",
accessOff: "No access",
accessGrantSuccess: "Project access granted.",
accessRevokeSuccess: "Project access revoked.",
accessLoadError: "Failed to load project access state.",
accessSaveError: "Failed to update project access.",
implicitAccessHint: "Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
creator: "Creator",
addUser: "Add user by mobile", addUser: "Add user by mobile",
addFromWorkspace: "Add from workspace", addFromWorkspace: "Add from workspace",
searchMembers: "Search members...", searchMembers: "Search members...",
@@ -558,13 +607,22 @@ export const en = {
deleteError: "Failed to delete tag.", deleteError: "Failed to delete tag.",
}, },
rates: { rates: {
workspaceSectionTitle: "Workspace User Rates", workspaceSectionTitle: "Workspace User Rates",
projectSectionTitle: "Project User Rates", projectSectionTitle: "Project User Rates",
workspaceRate: "Workspace rate", myRatesTitle: "My rates",
projectOverride: "Project override", myRatesHint: "Project-specific rates override your workspace rate in the current workspace.",
inheritsWorkspaceRate: "Inherits workspace rate", workspaceRate: "Workspace rate",
noRate: "No rate", workspaceRateHint: "This is your default rate unless a project-specific rate overrides it.",
projectOverride: "Project override",
projectOverrides: "Project overrides",
accessibleProjects: "Accessible projects",
workspaceFallbackProjects: "Using workspace rate",
projectOverrideHint: "Only projects with custom overrides are listed here. Other accessible projects use your workspace rate.",
projectOverrideEmpty: "You do not have any project-specific rate overrides in this workspace.",
myRatesEmpty: "No rates are available for this workspace yet.",
inheritsWorkspaceRate: "Inherits workspace rate",
noRate: "No rate",
hourlyRatePlaceholder: "0.00", hourlyRatePlaceholder: "0.00",
currencyPlaceholder: "USD", currencyPlaceholder: "USD",
searchUnitPlaceholder: "Search unit...", searchUnitPlaceholder: "Search unit...",
@@ -640,10 +698,14 @@ export const en = {
searchTagsLabel: "Search tags...", searchTagsLabel: "Search tags...",
noTagsFoundLabel: "No tags found.", noTagsFoundLabel: "No tags found.",
searchProjectsLabel: "Search projects...", searchProjectsLabel: "Search projects...",
noProjectsFoundLabel: "No projects found.", noProjectsFoundLabel: "No projects found.",
deletedProjectLabel: "Deleted project", deletedProjectLabel: "Deleted project",
deletedTagLabel: "Deleted tag", deletedTagLabel: "Deleted tag",
}, startRequiredError: "Start date and time are required.",
endRequiredError: "End date and time must both be filled.",
invalidEndTimeError: "End time is invalid.",
endBeforeStartError: "End must be after start.",
},
reports: { reports: {
title: "Reports", title: "Reports",
@@ -660,8 +722,9 @@ export const en = {
periodCustom: "Custom period", periodCustom: "Custom period",
fromDate: "From date", fromDate: "From date",
toDate: "To date", toDate: "To date",
user: "User", user: "User",
allUsers: "All users", mobile: "Mobile",
allUsers: "All users",
searchUsers: "Search users...", searchUsers: "Search users...",
client: "Client", client: "Client",
allClients: "All clients", allClients: "All clients",
@@ -678,16 +741,31 @@ export const en = {
totalHours: "Total hours", totalHours: "Total hours",
billableHours: "Billable hours", billableHours: "Billable hours",
nonBillableHours: "Non-billable hours", nonBillableHours: "Non-billable hours",
hourlyRate: "Hourly rate", hourlyRate: "Hourly rate",
totalIncome: "Total income", hourlyRates: "Hourly rates",
chartTitle: "Activity chart", workingHours: "Working hours",
nonWorkingHours: "Non-working hours",
totalIncome: "Total income",
projectPercentages: "Project percentages",
clientPercentages: "Client percentages",
tagPercentages: "Tag percentages",
userSummaryTitle: "Summary by user",
userSummaryDetailsTitle: "User details: {name}",
userSummaryDetailsDescription: "Review the selected user's rate history and time distribution.",
rateHistory: "Rate history",
percentage: "Percentage",
hourPercentage: "Hour %",
incomePercentage: "Income %",
now: "Now",
chartTitle: "Activity chart",
totalSeconds: "Total seconds", totalSeconds: "Total seconds",
exportExcel: "Export Excel", exportExcel: "Export Excel",
exportPdf: "Export PDF", exportPdf: "Export PDF",
date: "Date", date: "Date",
details: "Details", details: "Details",
total: "Total", total: "Total",
clientsTable: "Clients", noData: "No data",
clientsTable: "Clients",
projectsTable: "Projects", projectsTable: "Projects",
tagsTable: "Tags", tagsTable: "Tags",
loadError: "Failed to load reports.", loadError: "Failed to load reports.",

View File

@@ -59,6 +59,9 @@ export const fa = {
newPasswordPlaceholder: "رمز عبور جدید", newPasswordPlaceholder: "رمز عبور جدید",
confirmPasswordPlaceholder: "تکرار رمز عبور", confirmPasswordPlaceholder: "تکرار رمز عبور",
passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.", passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.",
passwordRequirements:
"رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.",
passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.",
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`, welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
enterPassword: "رمز عبور خود را وارد کنید", enterPassword: "رمز عبور خود را وارد کنید",
verifyNumber: "تایید شماره موبایل", verifyNumber: "تایید شماره موبایل",
@@ -76,22 +79,27 @@ export const fa = {
back: "بازگشت", back: "بازگشت",
otpPlaceholder: "کد ۵ رقمی", otpPlaceholder: "کد ۵ رقمی",
verifyAndContinue: "تایید و ادامه", verifyAndContinue: "تایید و ادامه",
sendingOtp: "در حال ارسال کد...",
verifyingOtp: "در حال تأیید کد...",
resendOtp: "ارسال دوباره کد",
otpExpiresIn: (time: string) => `اعتبار کد تا ${time} دیگر است`,
otpExpired: "اعتبار این کد به پایان رسیده است. برای ادامه کد جدید بگیرید.",
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت می‌کنید.", terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت می‌کنید.",
brandingQuote: "زمان و ورک‌اسپیس‌ها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.", brandingQuote: "زمان و ورک‌اسپیس‌ها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
toasts: { toasts: {
enterMobile: "لطفا شماره موبایل خود را وارد کنید", enterMobile: "لطفا شماره موبایل خود را وارد کنید",
verifySent: "کد تایید ارسال شد.", verifySent: "کد تایید ارسال شد.",
failedOtp: "ارسال کد تایید انجام نشد.", failedOtp: "ارسال کد تایید انجام نشد.",
fillAll: "لطفا تمام فیلدها را پر کنید.", fillAll: "لطفا تمام فیلدها را پر کنید.",
successLogin: "با موفقیت وارد شدید.", successLogin: "با موفقیت وارد شدید.",
accountCreated: "حساب با موفقیت ایجاد شد.", accountCreated: "حساب با موفقیت ایجاد شد.",
failedSignup: "تکمیل ثبت نام انجام نشد.", failedSignup: "تکمیل ثبت نام انجام نشد.",
invalidCreds: "اطلاعات ورود نامعتبر است.", invalidCreds: "اطلاعات ورود نامعتبر است.",
enterOtp: "لطفا کد تایید را وارد کنید.", enterOtp: "لطفا کد تایید را وارد کنید.",
invalidOtp: "کد تایید نامعتبر است.", invalidOtp: "کد تایید نامعتبر است.",
passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.", passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.",
passwordResetFailed: "تغییر رمز عبور انجام نشد." passwordResetFailed: "تغییر رمز عبور انجام نشد."
}, },
throttle: { throttle: {
title: "تعداد تلاش‌ها بیش از حد مجاز است", title: "تعداد تلاش‌ها بیش از حد مجاز است",
genericMessage: (time: string) => `درخواست‌های زیادی ارسال شده است. ${time} دیگر دوباره تلاش کنید.`, genericMessage: (time: string) => `درخواست‌های زیادی ارسال شده است. ${time} دیگر دوباره تلاش کنید.`,
@@ -107,15 +115,24 @@ export const fa = {
collectMobileTitle: "ساخت حساب را کامل کنید", collectMobileTitle: "ساخت حساب را کامل کنید",
collectMobileDescription: (email: string) => collectMobileDescription: (email: string) =>
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`, `حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
existingEmailClaimDescription: (email: string, mobileHint: string) =>
`حساب گوگل ${email} تایید شد. برای تایید مالکیت، شماره موبایل متصل به این حساب (${mobileHint}) را وارد کنید.`,
claimTitle: "حساب موجود خود را تایید کنید", claimTitle: "حساب موجود خود را تایید کنید",
claimDescription: (mobile: string) => claimDescription: (mobile: string) =>
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`, `حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
mobileClaimDescription: (mobile: string) =>
`حسابی بدون ایمیل با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
errorTitle: "ورود با گوگل کامل نشد", errorTitle: "ورود با گوگل کامل نشد",
cancelled: "فرآیند ورود با گوگل قبل از تکمیل لغو شد.",
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.", missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.", loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
callbackFailed: "تکمیل ورود با گوگل انجام نشد. لطفاً دوباره تلاش کنید.",
tokenExchangeFailed: "ورود با گوگل موقتاً در دسترس نیست. چند دقیقه دیگر دوباره تلاش کنید.",
profileLookupFailed: "دریافت اطلاعات حساب گوگل انجام نشد. لطفاً دوباره تلاش کنید.",
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.", completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
claimOtpSent: "کد تایید با موفقیت ارسال شد.", claimOtpSent: "کد تایید با موفقیت ارسال شد.",
googleAccount: "حساب گوگل", googleAccount: "حساب گوگل",
mobileHintLabel: (mobileHint: string) => `شماره مورد انتظار: ${mobileHint}`,
completeButton: "ادامه و ایجاد حساب", completeButton: "ادامه و ایجاد حساب",
verifyClaimButton: "تایید و ادامه", verifyClaimButton: "تایید و ادامه",
resendClaimOtp: "ارسال دوباره کد تایید", resendClaimOtp: "ارسال دوباره کد تایید",
@@ -193,31 +210,31 @@ export const fa = {
changePicture: "تغییر تصویر", changePicture: "تغییر تصویر",
save: "ذخیره", save: "ذخیره",
cancel: "لغو", cancel: "لغو",
upload: "آپلود", upload: "آپلود",
remove: "حذف", remove: "حذف",
imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید", imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید",
noEmail: "ایمیلی ثبت نشده", noEmail: "ایمیلی ثبت نشده",
password: { password: {
trigger: "تغییر رمز عبور", trigger: "تغییر رمز عبور",
title: "تغییر رمز عبور", title: "تغییر رمز عبور",
description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.", description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.",
currentPassword: "رمز عبور فعلی", currentPassword: "رمز عبور فعلی",
newPassword: "رمز عبور جدید", newPassword: "رمز عبور جدید",
confirmPassword: "تکرار رمز جدید", confirmPassword: "تکرار رمز جدید",
submit: "ذخیره رمز عبور", submit: "ذخیره رمز عبور",
saving: "در حال ذخیره...", saving: "در حال ذخیره...",
toasts: { toasts: {
success: "رمز عبور با موفقیت تغییر کرد.", success: "رمز عبور با موفقیت تغییر کرد.",
error: "تغییر رمز عبور انجام نشد.", error: "تغییر رمز عبور انجام نشد.",
}, },
}, },
toasts: { toasts: {
successEdit: "پروفایل با موفقیت به‌روزرسانی شد.", successEdit: "پروفایل با موفقیت به‌روزرسانی شد.",
successImage: "عکس پروفایل به‌روزرسانی شد.", successImage: "عکس پروفایل به‌روزرسانی شد.",
successRemoveImage: "عکس پروفایل حذف شد.", successRemoveImage: "عکس پروفایل حذف شد.",
error: "خطایی رخ داد." error: "خطایی رخ داد."
} }
}, },
workspace: { workspace: {
title: "مدیریت ورک‌اسپیس‌ها", title: "مدیریت ورک‌اسپیس‌ها",
@@ -268,6 +285,7 @@ export const fa = {
membersSectionTitle: "اعضا", membersSectionTitle: "اعضا",
membersSectionSubtitle: "اعضای این ورک‌اسپیس و نقش فعلی آن‌ها.", membersSectionSubtitle: "اعضای این ورک‌اسپیس و نقش فعلی آن‌ها.",
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.", membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
projectRateHint: "برای هر کاربر می‌توانید از صفحه پروژه‌ها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورک‌اسپیس اولویت داشته باشد.",
manageMembers: "مدیریت اعضا", manageMembers: "مدیریت اعضا",
mobileNumber: "شماره تماس", mobileNumber: "شماره تماس",
youLabel: "شما", youLabel: "شما",
@@ -283,8 +301,10 @@ export const fa = {
}, },
createdSuccess: "ورک‌اسپیس با موفقیت ایجاد شد", createdSuccess: "ورک‌اسپیس با موفقیت ایجاد شد",
updatedSuccess: "ورک‌اسپیس با موفقیت ویرایش شد", updatedSuccess: "ورک‌اسپیس با موفقیت ویرایش شد",
fetchError: "خطا در دریافت اطلاعات ورک‌اسپیس", fetchError: "خطا در دریافت اطلاعات ورک‌اسپیس",
remove: "حذف", loadErrorDescription: "ممکن است سرویس بک‌اند در دسترس نباشد. لطفاً چند لحظه بعد دوباره تلاش کنید.",
retry: "تلاش دوباره",
remove: "حذف",
noUsersFound: "کاربری یافت نشد", noUsersFound: "کاربری یافت نشد",
selectRole: "انتخاب نقش", selectRole: "انتخاب نقش",
add: "افزودن", add: "افزودن",
@@ -373,13 +393,14 @@ export const fa = {
collapse: 'جمع کردن', collapse: 'جمع کردن',
}, },
landing: { landing: {
brandLabel: "زیرساخت عملیاتی زمان", brandLabel: "زیرساخت عملیاتی زمان",
eyebrow: "طراحی‌شده برای تیم‌های دقیق که به داده زمانی قابل اتکا نیاز دارند", eyebrow: "طراحی‌شده برای تیم‌های دقیق که به داده زمانی قابل اتکا نیاز دارند",
nav: { nav: {
demo: "دموی محصول", demo: "دموی محصول",
features: "قابلیت‌ها", features: "قابلیت‌ها",
workflow: "فرآیند کار", workflow: "فرآیند کار",
about: "درباره ما",
}, },
actions: { actions: {
switchToEnglish: "English", switchToEnglish: "English",
@@ -388,8 +409,9 @@ export const fa = {
openApp: "ورود به اپ", openApp: "ورود به اپ",
openWorkspace: "باز کردن ورک‌اسپیس", openWorkspace: "باز کردن ورک‌اسپیس",
startNow: "شروع با کنترل کامل", startNow: "شروع با کنترل کامل",
watchDemo: "مشاهده دموی محصول", watchDemo: "مشاهده دموی محصول",
readTerms: "مطالعه قوانین", readTerms: "مطالعه قوانین",
readAbout: "درباره Qlockify",
}, },
hero: { hero: {
titleTop: "هر ساعت کاری را به یک سیگنال عملیاتی قابل اعتماد تبدیل کنید.", titleTop: "هر ساعت کاری را به یک سیگنال عملیاتی قابل اعتماد تبدیل کنید.",
@@ -459,7 +481,16 @@ export const fa = {
finalCtaTitle: "اگر تیم شما تخصص می‌فروشد یا پروژه مشتری تحویل می‌دهد، سیستم زمان شما هم باید همین‌قدر جدی باشد.", finalCtaTitle: "اگر تیم شما تخصص می‌فروشد یا پروژه مشتری تحویل می‌دهد، سیستم زمان شما هم باید همین‌قدر جدی باشد.",
finalCtaDescription: finalCtaDescription:
"اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.", "اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.",
}, },
demo: {
badge: "محیط دمو",
starting: "در حال آماده‌سازی دمو...",
started: "محیط دمو آماده شد.",
startError: "امکان ساخت محیط دمو وجود ندارد.",
expiresAt: "زمان انقضا",
resetAction: "شروع دوباره دمو",
reset: "محیط دموی تازه آماده شد.",
},
ordering: { ordering: {
createdAtDesc: "جدیدترین", createdAtDesc: "جدیدترین",
@@ -473,7 +504,7 @@ export const fa = {
title: "پروژه‌ها", title: "پروژه‌ها",
description: (workspaceName: string) => `مدیریت پروژه‌ها برای ${workspaceName}`, description: (workspaceName: string) => `مدیریت پروژه‌ها برای ${workspaceName}`,
active: "پروژه‌های فعال", active: "پروژه‌های فعال",
archived: "پروژه‌های بایگانی شده", archived: "بایگانی شده",
createNew: "ایجاد پروژه جدید", createNew: "ایجاد پروژه جدید",
searchPlaceholder: "جستجوی پروژه‌ها...", searchPlaceholder: "جستجوی پروژه‌ها...",
selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.", selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",
@@ -518,6 +549,24 @@ export const fa = {
}, },
namePlaceholder: "نام پروژه...", namePlaceholder: "نام پروژه...",
teamMembers: "اعضای تیم", teamMembers: "اعضای تیم",
manageAccess: "پروژه‌ها و نرخ‌ها",
accessModalTitle: "پروژه‌ها و نرخ‌ها",
accessModalDescription: "دسترسی پروژه‌ها را برای اعضا و مهمان‌ها مدیریت کنید و برای هر کاربر ورک‌اسپیس نرخ اختصاصی پروژه ثبت کنید.",
accessMemberLabel: "کاربر",
accessNoMembers: "کاربری در این ورک‌اسپیس پیدا نشد.",
accessNoProjects: "پروژه‌ای پیدا نشد.",
accessSelectVisible: "انتخاب همه موارد قابل مشاهده",
accessClearSelection: "پاک کردن انتخاب",
accessSelectClientProjects: "انتخاب همه پروژه‌های این مشتری",
accessGrant: "اعطای دسترسی به موارد انتخاب‌شده",
accessRevoke: "لغو دسترسی موارد انتخاب‌شده",
accessOn: "دارای دسترسی",
accessOff: "بدون دسترسی",
accessGrantSuccess: "دسترسی پروژه با موفقیت اعطا شد.",
accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.",
accessLoadError: "بارگذاری وضعیت دسترسی پروژه‌ها انجام نشد.",
accessSaveError: "به‌روزرسانی دسترسی پروژه‌ها انجام نشد.",
implicitAccessHint: "مالک‌ها و ادمین‌ها همیشه به همه پروژه‌ها دسترسی دارند. از اینجا فقط می‌توانید نرخ اختصاصی پروژه برای آن‌ها تنظیم کنید.",
createSuccess: "پروژه با موفقیت ایجاد شد.", createSuccess: "پروژه با موفقیت ایجاد شد.",
createError: "خطا در ایجاد پروژه.", createError: "خطا در ایجاد پروژه.",
updateSuccess: "پروژه با موفقیت به‌روزرسانی شد.", updateSuccess: "پروژه با موفقیت به‌روزرسانی شد.",
@@ -558,8 +607,17 @@ export const fa = {
rates: { rates: {
workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس", workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس",
projectSectionTitle: "نرخ‌های کاربران پروژه", projectSectionTitle: "نرخ‌های کاربران پروژه",
myRatesTitle: "تعرفه‌های من",
myRatesHint: "نرخ‌های اختصاصی پروژه در این ورک‌اسپیس روی نرخ پیش‌فرض شما اولویت دارند.",
workspaceRate: "دستمزد ساعتی", workspaceRate: "دستمزد ساعتی",
workspaceRateHint: "این نرخ پیش‌فرض شما است مگر این‌که برای یک پروژه نرخ اختصاصی ثبت شده باشد.",
projectOverride: "نرخ اختصاصی پروژه", projectOverride: "نرخ اختصاصی پروژه",
projectOverrides: "نرخ‌های اختصاصی پروژه",
accessibleProjects: "پروژه‌های دردسترس",
workspaceFallbackProjects: "با نرخ ورک‌اسپیس",
projectOverrideHint: "فقط پروژه‌هایی که نرخ اختصاصی دارند اینجا نمایش داده می‌شوند. بقیه پروژه‌های دردسترس از نرخ ورک‌اسپیس استفاده می‌کنند.",
projectOverrideEmpty: "برای شما در این ورک‌اسپیس هنوز نرخ اختصاصی پروژه‌ای ثبت نشده است.",
myRatesEmpty: "هنوز نرخی برای این ورک‌اسپیس ثبت نشده است.",
inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی", inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی",
noRate: "بدون نرخ", noRate: "بدون نرخ",
hourlyRatePlaceholder: "0.00", hourlyRatePlaceholder: "0.00",
@@ -637,10 +695,14 @@ export const fa = {
searchTagsLabel: "جست‌وجوی تگ‌ها...", searchTagsLabel: "جست‌وجوی تگ‌ها...",
noTagsFoundLabel: "تگی پیدا نشد.", noTagsFoundLabel: "تگی پیدا نشد.",
searchProjectsLabel: "جست‌وجوی پروژه‌ها...", searchProjectsLabel: "جست‌وجوی پروژه‌ها...",
noProjectsFoundLabel: "پروژه‌ای پیدا نشد.", noProjectsFoundLabel: "پروژه‌ای پیدا نشد.",
deletedProjectLabel: "پروژه حذف‌شده", deletedProjectLabel: "پروژه حذف‌شده",
deletedTagLabel: "تگ حذف‌شده", deletedTagLabel: "تگ حذف‌شده",
}, startRequiredError: "تاریخ و زمان شروع الزامی است.",
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
invalidEndTimeError: "زمان پایان معتبر نیست.",
endBeforeStartError: "پایان باید بعد از شروع باشد.",
},
reports: { reports: {
title: "گزارش‌ها", title: "گزارش‌ها",
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`, description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
@@ -657,6 +719,7 @@ export const fa = {
fromDate: "از تاریخ", fromDate: "از تاریخ",
toDate: "تا تاریخ", toDate: "تا تاریخ",
user: "کاربر", user: "کاربر",
mobile: "موبایل",
allUsers: "همه کاربران", allUsers: "همه کاربران",
searchUsers: "جست‌وجوی کاربران...", searchUsers: "جست‌وجوی کاربران...",
client: "مشتری", client: "مشتری",
@@ -675,7 +738,21 @@ export const fa = {
billableHours: "ساعات کاری", billableHours: "ساعات کاری",
nonBillableHours: "ساعات غیر کاری", nonBillableHours: "ساعات غیر کاری",
hourlyRate: "نرخ ساعتی", hourlyRate: "نرخ ساعتی",
totalIncome: "مجموع درآمد", hourlyRates: "نرخ‌های ساعتی",
workingHours: "ساعات کاری",
nonWorkingHours: "ساعات غیرکاری",
totalIncome: "مجموع کارکرد",
projectPercentages: "درصد پروژه‌ها",
clientPercentages: "درصد مشتری‌ها",
tagPercentages: "درصد تگ‌ها",
userSummaryTitle: "خلاصه کاربران",
userSummaryDetailsTitle: "جزئیات کاربر: {name}",
userSummaryDetailsDescription: "تاریخچه نرخ‌های ساعتی و توزیع زمان کار برای کاربر انتخاب‌شده را بررسی کنید.",
rateHistory: "تاریخچه نرخ‌ها",
percentage: "درصد",
hourPercentage: "درصد ساعت",
incomePercentage: "درصد کارکرد",
now: "حال",
chartTitle: "نمودار فعالیت", chartTitle: "نمودار فعالیت",
totalSeconds: "مجموع ثانیه", totalSeconds: "مجموع ثانیه",
exportExcel: "خروجی Excel", exportExcel: "خروجی Excel",
@@ -683,6 +760,7 @@ export const fa = {
date: "تاریخ", date: "تاریخ",
details: "جزئیات", details: "جزئیات",
total: "مجموع", total: "مجموع",
noData: "داده‌ای وجود ندارد",
clientsTable: "مشتری‌ها", clientsTable: "مشتری‌ها",
projectsTable: "پروژه‌ها", projectsTable: "پروژه‌ها",
tagsTable: "تگ‌ها", tagsTable: "تگ‌ها",

View File

@@ -1,13 +1,10 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { AppProvider } from './context/AppContext';
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<AppProvider> <App />
<App />
</AppProvider>
</React.StrictMode> </React.StrictMode>
); );

499
src/pages/About.tsx Normal file
View File

@@ -0,0 +1,499 @@
import { useState, type FormEvent } from "react"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import {
ArrowLeft,
ArrowRight,
AtSign,
BarChart3,
CheckCircle2,
Command,
FileText,
Globe2,
Layers3,
LockKeyhole,
Mail,
MessageCircle,
Moon,
Phone,
Send,
ShieldCheck,
Sparkles,
Sun,
TimerReset,
Users,
Waypoints,
} from "lucide-react"
import { Button } from "../components/ui/button"
import { Input } from "../components/ui/input"
import { TextAreaInput } from "../components/ui/TextAreaInput"
import { useTheme } from "../components/ThemeProvider"
import { useTranslation } from "../hooks/useTranslation"
import { submitContactForm } from "../api/contact"
import aboutContent from "../content/about.json"
import { cn } from "../lib/utils"
type AboutContent = typeof aboutContent.en
const sectionIcons = [Sparkles, ShieldCheck, Layers3]
const principleIcons = [TimerReset, Waypoints, BarChart3]
const capabilityIcons = [TimerReset, Users, FileText, LockKeyhole]
const contactIcons = [MessageCircle, MessageCircle, Mail, Phone]
export default function About() {
const navigate = useNavigate()
const { t, lang, setLanguage } = useTranslation()
const { theme, setTheme } = useTheme()
const [contactForm, setContactForm] = useState({
firstName: "",
lastName: "",
email: "",
mobile: "",
message: "",
})
const [isContactSubmitting, setIsContactSubmitting] = useState(false)
const content = aboutContent[lang] as AboutContent
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
const isDarkMode =
theme === "dark" ||
(theme === "system" && document.documentElement.classList.contains("dark"))
const ctaTarget = isAuthenticated ? "/timesheet" : "/auth"
const updateContactField = (field: keyof typeof contactForm, value: string) => {
setContactForm((current) => ({ ...current, [field]: value }))
}
const handleContactSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsContactSubmitting(true)
try {
await submitContactForm({
first_name: contactForm.firstName.trim(),
last_name: contactForm.lastName.trim(),
email: contactForm.email.trim(),
mobile: contactForm.mobile.trim(),
message: contactForm.message.trim(),
})
setContactForm({
firstName: "",
lastName: "",
email: "",
mobile: "",
message: "",
})
toast.success(content.contact.success)
} catch (error) {
toast.error(error instanceof Error ? error.message : content.contact.error)
} finally {
setIsContactSubmitting(false)
}
}
return (
<div className="scroll-smooth min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50">
<div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" />
<div className="landing-hero-grid pointer-events-none fixed inset-0 opacity-70 dark:opacity-40" />
<div className="pointer-events-none fixed left-[-12rem] top-20 h-80 w-80 rounded-full bg-cyan-400/20 blur-3xl dark:bg-cyan-500/10" />
<div className="pointer-events-none fixed right-[-10rem] top-52 h-72 w-72 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-400/10" />
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-4 pb-14 pt-5 sm:px-6 lg:px-8">
<header className="animate-landing-rise flex items-center justify-between rounded-full border border-white/70 bg-white/75 px-4 py-3 shadow-[0_20px_60px_-36px_rgba(15,23,42,0.45)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/55">
<button
type="button"
onClick={() => navigate("/")}
className="inline-flex items-center gap-3 rounded-full px-2 py-1 text-left"
>
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-950 text-white shadow-lg shadow-cyan-500/20 dark:bg-white dark:text-slate-950">
<Command className="h-5 w-5" />
</span>
<span className="hidden sm:block">
<span className="block text-lg font-semibold">{t.title}</span>
</span>
</button>
<nav className="hidden items-center gap-2 md:flex">
<Link
to="/"
className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition dark:text-slate-200 dark:hover:text-white"
>
{lang === "fa" ? "خانه" : "Home"}
</Link>
<Link
to="/about"
className="rounded-ful px-4 py-2 text-sm text-white shadow-sm font-bold"
>
{lang === "fa" ? "درباره ما" : "About us"}
</Link>
</nav>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setLanguage(lang === "fa" ? "en" : "fa")}
className="inline-flex h-11 items-center gap-2 rounded-full border border-slate-200/80 bg-white/80 px-4 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-950/80 dark:text-slate-200 dark:hover:bg-slate-900"
>
<Globe2 className="h-4 w-4" />
{lang === "fa" ? t.landing.actions.switchToEnglish : t.landing.actions.switchToPersian}
</button>
<button
type="button"
onClick={() => setTheme(isDarkMode ? "light" : "dark")}
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200/80 bg-white/80 text-slate-700 transition hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-950/80 dark:text-slate-200 dark:hover:bg-slate-900"
aria-label={isDarkMode ? t.lightMode : t.darkMode}
>
{isDarkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
</div>
</header>
<main className="flex-1">
<section className="grid items-center gap-10 py-12 lg:grid-cols-[1.05fr_0.95fr] lg:py-20">
<div className="space-y-7">
<div className="animate-landing-rise">
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-cyan-200/70 bg-white/75 px-4 py-2 text-sm font-medium text-cyan-900 shadow-sm backdrop-blur dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<Sparkles className="h-4 w-4" />
{content.eyebrow}
</div>
<h1 className="max-w-4xl text-4xl font-semibold leading-[1.08] text-slate-950 sm:text-5xl lg:text-6xl dark:text-white">
{content.hero.title}
<span className="landing-shimmer mt-4 block bg-[linear-gradient(120deg,#0f172a_15%,#0891b2_48%,#0f766e_78%,#0f172a_100%)] bg-clip-text pb-4 text-transparent dark:bg-[linear-gradient(120deg,#ffffff_18%,#67e8f9_48%,#2dd4bf_78%,#ffffff_100%)]">
{content.hero.accent}
</span>
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600 dark:text-slate-300 sm:text-xl">
{content.hero.description}
</p>
</div>
<div className="animate-landing-rise flex flex-col gap-3 sm:flex-row [animation-delay:140ms]">
<Button
asChild
className="h-14 rounded-full bg-slate-950 px-7 text-base text-white shadow-[0_30px_80px_-28px_rgba(8,145,178,0.45)] hover:bg-slate-800 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
>
<Link to={ctaTarget}>
{content.cta.button}
<ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rotate-180")} />
</Link>
</Button>
<Button
asChild
variant="outline"
className="h-14 rounded-full border-slate-200 bg-white/85 px-7 text-base text-slate-800 shadow-sm backdrop-blur hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
>
<Link to="/">
{lang === "fa" ? "بازگشت به صفحه اصلی" : "Back to home"}
{lang === "fa" ? <ArrowLeft className="ms-2 h-4 w-4" /> : <ArrowRight className="ms-2 h-4 w-4" />}
</Link>
</Button>
</div>
</div>
<div className="animate-landing-rise [animation-delay:180ms]">
<div className="relative overflow-hidden rounded-[2rem] border border-white/70 bg-white/80 p-5 shadow-[0_45px_110px_-48px_rgba(15,23,42,0.6)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/70 sm:p-6">
<div className="absolute inset-x-0 top-0 h-24 bg-[linear-gradient(90deg,rgba(34,211,238,0.16),rgba(16,185,129,0.12),rgba(245,158,11,0.12))]" />
<div className="relative">
<div className="mb-6 flex items-center justify-between">
<div>
<div className="text-xs uppercase tracking-[0.22em] text-slate-400 dark:text-slate-500">
{lang === "fa" ? "مدل محصول" : "Product model"}
</div>
<div className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{lang === "fa" ? "از ثبت تا تصمیم" : "From capture to decision"}
</div>
</div>
<div className="rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200">
{lang === "fa" ? "شفاف" : "Clear"}
</div>
</div>
<div className="space-y-3">
{content.sections.map((section, index) => {
const Icon = sectionIcons[index] ?? Sparkles
return (
<div
key={section.title}
className="rounded-[1.5rem] border border-slate-200/80 bg-white/80 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/80"
>
<div className="flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950 dark:text-white">
{section.title}
</h2>
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-300">
{section.description}
</p>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</section>
<section className="grid gap-4 py-8 md:grid-cols-3">
{content.principles.map((principle, index) => {
const Icon = principleIcons[index] ?? CheckCircle2
return (
<article
key={principle.title}
className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/80 p-6 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/65"
style={{ animationDelay: `${index * 120}ms` }}
>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Icon className="h-5 w-5" />
</div>
<h2 className="mt-5 text-2xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{principle.title}
</h2>
<p className="mt-3 text-base leading-7 text-slate-600 dark:text-slate-300">
{principle.description}
</p>
</article>
)
})}
</section>
<section className="grid gap-6 py-8 lg:grid-cols-[0.9fr_1.1fr]">
<div className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/80 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/65">
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
{lang === "fa" ? "برای چه تیم‌هایی" : "Who it serves"}
</div>
<h2 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-slate-950 dark:text-white">
{lang === "fa" ? "برای تیم‌هایی که زمان را بخشی از عملیات می‌دانند." : "For teams that treat time as part of operations."}
</h2>
<div className="mt-6 flex flex-wrap gap-3">
{content.audience.map((item) => (
<span
key={item}
className="rounded-full border border-cyan-200/70 bg-cyan-50/70 px-4 py-2 text-sm font-medium text-cyan-900 backdrop-blur dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100"
>
{item}
</span>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{content.capabilities.map((capability, index) => {
const Icon = capabilityIcons[index] ?? Waypoints
return (
<article
key={capability.title}
className="animate-landing-rise rounded-[2rem] border border-white/70 bg-gradient-to-br from-white/95 to-slate-50/80 p-6 shadow-[0_26px_70px_-48px_rgba(15,23,42,0.65)] backdrop-blur-xl dark:border-white/10 dark:from-slate-950/80 dark:to-slate-900/55"
style={{ animationDelay: `${index * 90}ms` }}
>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-white dark:text-slate-950">
<Icon className="h-5 w-5" />
</div>
<h3 className="text-xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{capability.title}
</h3>
</div>
<p className="mt-4 text-sm leading-7 text-slate-600 dark:text-slate-300">
{capability.description}
</p>
</article>
)
})}
</div>
</section>
<section className="py-8">
<div className="rounded-[2rem] border border-white/70 bg-white/80 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/65">
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
{lang === "fa" ? "اعتماد و کنترل" : "Trust and control"}
</div>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-slate-950 dark:text-white">
{lang === "fa" ? "جزئیات مهم باید قابل بررسی بمانند." : "Important details should remain reviewable."}
</h2>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{content.trust.map((item, index) => (
<div
key={item}
className="rounded-[1.5rem] border border-slate-200/80 bg-white/80 p-4 text-sm leading-7 text-slate-600 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/80 dark:text-slate-300"
>
<CheckCircle2 className="mb-4 h-5 w-5 text-emerald-500" />
{item}
<div className="mt-5 text-xs font-semibold uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500">
{lang === "fa" ? `نکته ${new Intl.NumberFormat("fa-IR").format(index + 1)}` : `Note ${index + 1}`}
</div>
</div>
))}
</div>
</div>
</section>
<section className="grid gap-6 py-8 lg:grid-cols-[0.95fr_1.05fr]">
<div className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/80 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/65">
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
{content.contact.eyebrow}
</div>
<h2 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-slate-950 dark:text-white">
{content.contact.title}
</h2>
<p className="mt-4 text-base leading-8 text-slate-600 dark:text-slate-300">
{content.contact.description}
</p>
<div className="mt-7 grid gap-3">
{content.contact.channels.map((channel, index) => {
const Icon = contactIcons[index] ?? AtSign
return (
<a
key={channel.label}
href={channel.href}
target={channel.href.startsWith("http") ? "_blank" : undefined}
rel={channel.href.startsWith("http") ? "noreferrer" : undefined}
className="group flex items-center justify-between gap-4 rounded-2xl border border-slate-200/80 bg-white/80 p-4 text-start shadow-sm transition hover:border-cyan-300 hover:bg-cyan-50/70 dark:border-slate-800 dark:bg-slate-900/75 dark:hover:border-cyan-500/40 dark:hover:bg-cyan-950/30"
>
<span className="flex min-w-0 items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Icon className="h-5 w-5" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-slate-950 dark:text-white">
{channel.label}
</span>
<span className="block truncate text-sm text-slate-600 dark:text-slate-300">
{channel.value}
</span>
</span>
</span>
{lang === "fa" ? (
<ArrowLeft className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-cyan-600 dark:group-hover:text-cyan-300" />
) : (
<ArrowRight className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-cyan-600 dark:group-hover:text-cyan-300" />
)}
</a>
)
})}
</div>
</div>
<form
onSubmit={handleContactSubmit}
className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/85 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70"
>
<div className="mb-6 flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Send className="h-5 w-5" />
</span>
<h2 className="text-2xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{content.contact.formTitle}
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.firstName}
<Input
value={contactForm.firstName}
onChange={(event) => updateContactField("firstName", event.target.value)}
placeholder={content.contact.placeholders.firstName}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.lastName}
<Input
value={contactForm.lastName}
onChange={(event) => updateContactField("lastName", event.target.value)}
placeholder={content.contact.placeholders.lastName}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.email}
<Input
type="email"
value={contactForm.email}
onChange={(event) => updateContactField("email", event.target.value)}
placeholder={content.contact.placeholders.email}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.mobile}
<Input
value={contactForm.mobile}
onChange={(event) => updateContactField("mobile", event.target.value)}
placeholder={content.contact.placeholders.mobile}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
</div>
<label className="mt-4 block text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.message}
<TextAreaInput
value={contactForm.message}
onChange={(event) => updateContactField("message", event.target.value)}
placeholder={content.contact.placeholders.message}
className="mt-2 min-h-40 rounded-2xl"
required
/>
</label>
<Button
type="submit"
disabled={isContactSubmitting}
className="mt-6 h-14 w-full rounded-full bg-slate-950 px-7 text-base text-white hover:bg-slate-800 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
>
<Send className="me-2 h-4 w-4" />
{isContactSubmitting ? content.contact.submitting : content.contact.submit}
</Button>
</form>
</section>
<section className="py-8">
<div className="relative overflow-hidden rounded-[2.5rem] border border-slate-950/5 bg-slate-950 px-6 py-10 text-white shadow-[0_40px_100px_-40px_rgba(15,23,42,0.8)] dark:border-white/10 sm:px-10">
<div className="pointer-events-none absolute inset-y-0 right-0 w-[45%] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.35),transparent_55%),radial-gradient(circle_at_bottom_right,rgba(16,185,129,0.24),transparent_45%)]" />
<div className="relative flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-300">
{lang === "fa" ? "شروع ساده" : "Simple start"}
</div>
<h2 className="mt-4 text-4xl font-semibold leading-[1.1] tracking-[-0.05em] sm:text-5xl">
{content.cta.title}
</h2>
<p className="mt-4 text-lg leading-8 text-slate-300">{content.cta.description}</p>
</div>
<Button
asChild
className="h-14 rounded-full bg-white px-7 text-base font-semibold text-slate-950 hover:bg-slate-100"
>
<Link to={ctaTarget}>
{content.cta.button}
<ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rotate-180")} />
</Link>
</Button>
</div>
</div>
</section>
</main>
</div>
</div>
)
}

View File

@@ -190,7 +190,11 @@ export default function Clients() {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{client.name.trim().charAt(0).toUpperCase() || "C"} {client.thumbnail ? (
<img src={client.thumbnail} alt={client.name} className="h-full w-full rounded-xl object-cover" />
) : (
client.name.trim().charAt(0).toUpperCase() || "C"
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle> <CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>

View File

@@ -17,6 +17,7 @@ import {
} from "../api/users"; } from "../api/users";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { setSessionTokens } from "../lib/session"; import { setSessionTokens } from "../lib/session";
import { AuthOtpInput } from "./auth/AuthOtpInput";
type GoogleStep = "loading" | "collect_mobile" | "claim_required" | "error"; type GoogleStep = "loading" | "collect_mobile" | "claim_required" | "error";
type CooldownKey = "otpSend" | "otpVerify"; type CooldownKey = "otpSend" | "otpVerify";
@@ -33,18 +34,40 @@ export default function GoogleAuthCallback() {
const isRtl = lang === "fa"; const isRtl = lang === "fa";
const flow = searchParams.get("flow") ?? ""; const flow = searchParams.get("flow") ?? "";
const callbackErrorCode = searchParams.get("error") ?? "";
const callbackErrorDescription = searchParams.get("error_description") ?? "";
const [step, setStep] = useState<GoogleStep>("loading"); const [step, setStep] = useState<GoogleStep>("loading");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [mobile, setMobile] = useState(""); const [mobile, setMobile] = useState("");
const [otpCode, setOtpCode] = useState(""); const [otpCode, setOtpCode] = useState("");
const [googleEmail, setGoogleEmail] = useState(""); const [googleEmail, setGoogleEmail] = useState("");
const [flowResolution, setFlowResolution] = useState<
"new_account" | "existing_email_claim" | "existing_mobile_claim" | null
>(null);
const [mobileHint, setMobileHint] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [cooldowns, setCooldowns] = useState<Record<CooldownKey, number>>({ const [cooldowns, setCooldowns] = useState<Record<CooldownKey, number>>({
otpSend: 0, otpSend: 0,
otpVerify: 0, otpVerify: 0,
}); });
const resolveCallbackErrorMessage = () => {
if (callbackErrorCode === "access_denied") {
return t.login.google.cancelled;
}
if (callbackErrorDescription) {
if (callbackErrorDescription === "Google token exchange failed.") {
return t.login.google.tokenExchangeFailed;
}
if (callbackErrorDescription === "Google user profile lookup failed.") {
return t.login.google.profileLookupFailed;
}
return callbackErrorDescription;
}
return t.login.google.callbackFailed;
};
useEffect(() => { useEffect(() => {
if (!Object.values(cooldowns).some((value) => value > 0)) { if (!Object.values(cooldowns).some((value) => value > 0)) {
return; return;
@@ -93,12 +116,20 @@ export default function GoogleAuthCallback() {
if (payload.status === "collect_mobile") { if (payload.status === "collect_mobile") {
setGoogleEmail(payload.email); setGoogleEmail(payload.email);
setFlowResolution(payload.resolution);
setMobileHint(payload.mobile_hint ?? null);
setErrorMessage("");
setStep("collect_mobile"); setStep("collect_mobile");
return; return;
} }
if (payload.status === "claim_required") { if (payload.status === "claim_required") {
setMobile(payload.mobile); setMobile(payload.mobile);
setOtpCode("");
setGoogleEmail(payload.email);
setFlowResolution(payload.resolution);
setMobileHint(payload.mobile_hint ?? null);
setErrorMessage("");
setStep("claim_required"); setStep("claim_required");
} }
}; };
@@ -125,6 +156,12 @@ export default function GoogleAuthCallback() {
}; };
useEffect(() => { useEffect(() => {
if (callbackErrorCode) {
setErrorMessage(resolveCallbackErrorMessage());
setStep("error");
return;
}
if (!flow) { if (!flow) {
setErrorMessage(t.login.google.missingFlow); setErrorMessage(t.login.google.missingFlow);
setStep("error"); setStep("error");
@@ -157,7 +194,7 @@ export default function GoogleAuthCallback() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [flow]); }, [callbackErrorCode, callbackErrorDescription, flow, lang]);
const handleCompleteSignup = async (event: React.FormEvent) => { const handleCompleteSignup = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
@@ -171,10 +208,17 @@ export default function GoogleAuthCallback() {
const payload = await completeGoogleOAuthSignup(flow, mobile); const payload = await completeGoogleOAuthSignup(flow, mobile);
applyFlowPayload(payload); applyFlowPayload(payload);
if (payload.status === "claim_required") { if (payload.status === "claim_required") {
toast.success(t.login.google.claimOtpSent); toast.success(payload.resolution === "new_account" ? t.login.toasts.verifySent : t.login.google.claimOtpSent);
} }
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : t.login.google.completeFailed); const message = error instanceof Error ? error.message : t.login.google.completeFailed;
setErrorMessage(message);
if (error instanceof ApiError) {
if (error.code === "google_email_mobile_conflict" || error.code === "google_mobile_belongs_to_other_email") {
setStep("collect_mobile");
}
}
toast.error(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -255,18 +299,35 @@ export default function GoogleAuthCallback() {
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{step === "loading" && t.login.google.loadingTitle} {step === "loading" && t.login.google.loadingTitle}
{step === "collect_mobile" && t.login.google.collectMobileTitle} {step === "collect_mobile" && t.login.google.collectMobileTitle}
{step === "claim_required" && t.login.google.claimTitle} {step === "claim_required" &&
(flowResolution === "new_account" ? t.login.signupVerifyTitle : t.login.google.claimTitle)}
{step === "error" && t.login.google.errorTitle} {step === "error" && t.login.google.errorTitle}
</h1> </h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> <p className="text-sm text-slate-500 dark:text-slate-400">
{step === "loading" && t.login.google.loadingDescription} {step === "loading" && t.login.google.loadingDescription}
{step === "collect_mobile" && {step === "collect_mobile" &&
t.login.google.collectMobileDescription(googleEmail || "-")} (flowResolution === "existing_email_claim"
{step === "claim_required" && t.login.google.claimDescription(mobile)} ? t.login.google.existingEmailClaimDescription(googleEmail || "-", mobileHint || "-")
: t.login.google.collectMobileDescription(googleEmail || "-"))}
{step === "claim_required" &&
(flowResolution === "existing_email_claim"
? t.login.google.claimDescription(mobileHint || mobile)
: flowResolution === "existing_mobile_claim"
? t.login.google.mobileClaimDescription(mobile)
: t.login.sentCodeDesc(mobile))}
{step === "error" && (errorMessage || t.login.google.loadFailed)} {step === "error" && (errorMessage || t.login.google.loadFailed)}
</p> </p>
</div> </div>
{errorMessage && step !== "error" && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-start text-red-800 shadow-sm dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-100">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
</div>
)}
{activeWarning && ( {activeWarning && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-start text-amber-900 shadow-sm dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100"> <div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-start text-amber-900 shadow-sm dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -290,7 +351,7 @@ export default function GoogleAuthCallback() {
)} )}
{step === "collect_mobile" && ( {step === "collect_mobile" && (
<form onSubmit={handleCompleteSignup} className="grid gap-4"> <form key={`collect-mobile-${flowResolution ?? "default"}`} onSubmit={handleCompleteSignup} className="grid gap-4">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"> <div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center gap-3"> <div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200"> <div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200">
@@ -303,13 +364,21 @@ export default function GoogleAuthCallback() {
<p className="truncate text-sm text-slate-500 dark:text-slate-400">{googleEmail}</p> <p className="truncate text-sm text-slate-500 dark:text-slate-400">{googleEmail}</p>
</div> </div>
</div> </div>
{flowResolution === "existing_email_claim" && mobileHint && (
<div className="mb-4 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-100">
{t.login.google.mobileHintLabel(mobileHint)}
</div>
)}
<Input <Input
id="google-mobile" id="google-mobile"
placeholder={t.login.mobilePlaceholder} placeholder={t.login.mobilePlaceholder}
type="tel" type="tel"
dir="ltr" dir="ltr"
value={mobile} value={mobile}
onChange={(event) => setMobile(event.target.value)} onChange={(event) => {
setMobile(event.target.value)
setErrorMessage("")
}}
maxLength={11} maxLength={11}
disabled={loading} disabled={loading}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`} className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
@@ -327,21 +396,26 @@ export default function GoogleAuthCallback() {
)} )}
{step === "claim_required" && ( {step === "claim_required" && (
<form onSubmit={handleVerifyClaim} className="grid gap-4"> <form key={`claim-required-${flowResolution ?? "default"}`} onSubmit={handleVerifyClaim} className="grid gap-4">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"> <div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400"> <p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
{t.login.google.claimDescription(mobile)} {flowResolution === "existing_email_claim"
? t.login.google.claimDescription(mobileHint || mobile)
: flowResolution === "existing_mobile_claim"
? t.login.google.mobileClaimDescription(mobile)
: t.login.sentCodeDesc(mobile)}
</p> </p>
<Input <AuthOtpInput
id="google-claim-otp" id="google-claim-otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
value={otpCode} value={otpCode}
onChange={(event) => setOtpCode(event.target.value)}
maxLength={6}
disabled={loading} disabled={loading}
className="h-11 text-center text-lg tracking-widest" onChange={(value) => {
setOtpCode(value)
setErrorMessage("")
}}
onComplete={(value) => {
setOtpCode(value)
}}
/> />
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react" import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom" import { Link, useNavigate } from "react-router-dom"
import { import {
ArrowRight, ArrowRight,
@@ -15,11 +15,14 @@ import {
TimerReset, TimerReset,
Waypoints, Waypoints,
} from "lucide-react" } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { useTheme } from "../components/ThemeProvider" import { useTheme } from "../components/ThemeProvider"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
import { cn } from "../lib/utils" import { cn } from "../lib/utils"
import { startDemo } from "../api/demo"
import { setDemoSessionMeta, setSessionTokens } from "../lib/session"
const formatNumber = (value: number, lang: "en" | "fa") => const formatNumber = (value: number, lang: "en" | "fa") =>
new Intl.NumberFormat(lang === "fa" ? "fa-IR" : "en-US").format(value) new Intl.NumberFormat(lang === "fa" ? "fa-IR" : "en-US").format(value)
@@ -28,6 +31,7 @@ export default function Landing() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang, setLanguage } = useTranslation() const { t, lang, setLanguage } = useTranslation()
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const [isStartingDemo, setIsStartingDemo] = useState(false)
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken") const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
const isDarkMode = const isDarkMode =
@@ -87,6 +91,23 @@ export default function Landing() {
const ctaTarget = isAuthenticated ? "/timesheet" : "/auth" const ctaTarget = isAuthenticated ? "/timesheet" : "/auth"
const handleStartDemo = async () => {
if (isStartingDemo) return
setIsStartingDemo(true)
try {
const demo = await startDemo()
setSessionTokens(demo.access, demo.refresh)
setDemoSessionMeta(demo.expires_at)
toast.success(t.demo?.started || "Demo environment is ready.")
navigate("/timesheet")
} catch (error) {
console.error(error)
toast.error(t.demo?.startError || "Could not start the demo environment.")
} finally {
setIsStartingDemo(false)
}
}
return ( return (
<div className="scroll-smooth min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50"> <div className="scroll-smooth min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50">
<div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" /> <div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" />
@@ -110,15 +131,12 @@ export default function Landing() {
</button> </button>
<div className="hidden items-center gap-2 md:flex"> <div className="hidden items-center gap-2 md:flex">
<a href="#demo" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"> <Link to="/" className="rounded-ful px-4 py-2 text-sm text-white shadow-sm font-bold">
{t.landing.nav.demo} {lang === "fa" ? "خانه" : "Home"}
</a> </Link>
<a href="#features" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"> <Link to="/about" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition dark:text-slate-200 dark:hover:text-white">
{t.landing.nav.features} {t.landing.nav.about}
</a> </Link>
<a href="#workflow" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white">
{t.landing.nav.workflow}
</a>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -141,14 +159,14 @@ export default function Landing() {
</div> </div>
</header> </header>
<section className="relative grid flex-1 items-center gap-10 py-12 lg:grid-cols-[1.08fr_0.92fr] lg:py-20"> <section className="relative grid flex-1 items-start items-center gap-10 py-12 lg:grid-cols-[1.08fr_0.92fr] lg:py-20">
<div className="space-y-8"> <div className="space-y-8">
<div className="animate-landing-rise [animation-delay:120ms]"> <div className="animate-landing-rise [animation-delay:120ms]">
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-cyan-200/70 bg-white/75 px-4 py-2 text-sm font-medium text-cyan-900 shadow-sm backdrop-blur dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100"> <div className="mb-5 inline-flex items-center gap-2 rounded-full border border-cyan-200/70 bg-white/75 px-4 py-2 text-sm font-medium text-cyan-900 shadow-sm backdrop-blur dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
{t.landing.eyebrow} {t.landing.eyebrow}
</div> </div>
<h1 className="max-w-4xl text-4xl font-semibold leading-[1.1] text-slate-950 sm:text-5xl lg:text-6xl dark:text-white"> <h1 className="max-w-4xl text-4xl font-semibold leading-[1.1] text-slate-950 sm:text-4xl lg:text-5xl 2xl:text-6xl dark:text-white">
{t.landing.hero.titleTop} {t.landing.hero.titleTop}
<span className="mt-4 pb-4 landing-shimmer block bg-[linear-gradient(120deg,#0f172a_15%,#0891b2_48%,#0f766e_78%,#0f172a_100%)] bg-clip-text text-transparent dark:bg-[linear-gradient(120deg,#ffffff_18%,#67e8f9_48%,#2dd4bf_78%,#ffffff_100%)]"> <span className="mt-4 pb-4 landing-shimmer block bg-[linear-gradient(120deg,#0f172a_15%,#0891b2_48%,#0f766e_78%,#0f172a_100%)] bg-clip-text text-transparent dark:bg-[linear-gradient(120deg,#ffffff_18%,#67e8f9_48%,#2dd4bf_78%,#ffffff_100%)]">
{t.landing.hero.titleAccent} {t.landing.hero.titleAccent}
@@ -167,12 +185,14 @@ export default function Landing() {
{isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow} {isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow}
<ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rtl:rotate-180")} /> <ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rtl:rotate-180")} />
</Button> </Button>
<a <button
href="#demo" type="button"
className="inline-flex h-14 items-center justify-center rounded-full border border-slate-200 bg-white/85 px-7 text-base font-medium text-slate-800 shadow-sm backdrop-blur transition hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900" onClick={handleStartDemo}
disabled={isStartingDemo}
className="inline-flex h-14 items-center justify-center rounded-full border border-slate-200 bg-white/85 px-7 text-base font-medium text-slate-800 shadow-sm backdrop-blur transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-70 dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
> >
{t.landing.actions.watchDemo} {isStartingDemo ? t.demo?.starting || "Preparing demo..." : t.landing.actions.watchDemo}
</a> </button>
</div> </div>
<div className="animate-landing-rise grid gap-3 sm:grid-cols-3 [animation-delay:320ms]"> <div className="animate-landing-rise grid gap-3 sm:grid-cols-3 [animation-delay:320ms]">
@@ -385,31 +405,38 @@ export default function Landing() {
<section className="py-8"> <section className="py-8">
<div className="relative overflow-hidden rounded-[2.5rem] border border-slate-950/5 bg-slate-950 px-6 py-10 text-white shadow-[0_40px_100px_-40px_rgba(15,23,42,0.8)] dark:border-white/10 sm:px-10"> <div className="relative overflow-hidden rounded-[2.5rem] border border-slate-950/5 bg-slate-950 px-6 py-10 text-white shadow-[0_40px_100px_-40px_rgba(15,23,42,0.8)] dark:border-white/10 sm:px-10">
<div className="pointer-events-none absolute inset-y-0 right-0 w-[45%] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.35),transparent_55%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.22),transparent_45%)]" /> <div className="flex flex-row pointer-events-none absolute inset-y-0 right-0 w-[45%] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.35),transparent_55%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.22),transparent_45%)]" />
<div className="relative flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> <div className="relative flex flex-col lg:flex-row gap-6 justify-between">
<div className="max-w-3xl"> <div className="max-w-4xl">
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-300">{t.landing.finalCtaTag}</div> <div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-300">{t.landing.finalCtaTag}</div>
<h2 className="mt-4 text-4xl font-semibold leading-[1.1] tracking-[-0.05em] sm:text-5xl"> <h2 className="mt-4 text-4xl font-semibold leading-[1.1] tracking-[-0.05em] sm:text-5xl">
{t.landing.finalCtaTitle} {t.landing.finalCtaTitle}
</h2> </h2>
<p className="mt-4 text-lg leading-8 text-slate-300">{t.landing.finalCtaDescription}</p> <p className="mt-4 text-lg leading-8 text-slate-300">{t.landing.finalCtaDescription}</p>
</div>
<div className="flex flex-row lg:flex-col gap-3">
<Button
onClick={() => navigate(ctaTarget)}
className="h-14 rounded-full bg-white px-7 text-base font-semibold text-slate-950 hover:bg-slate-100"
>
{isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow}
</Button>
<Button
variant="outline"
asChild
className="h-14 rounded-full border-white/20 bg-white/5 px-7 text-base text-white hover:bg-white/10 dark:border-white/20 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
>
<Link to="/terms">{t.landing.actions.readTerms}</Link>
</Button>
<Button
variant="outline"
asChild
className="h-14 rounded-full border-white/20 bg-white/5 px-7 text-base text-white hover:bg-white/10 dark:border-white/20 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
>
<Link to="/about">{t.landing.actions.readAbout}</Link>
</Button>
</div>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => navigate(ctaTarget)}
className="h-14 rounded-full bg-white px-7 text-base font-semibold text-slate-950 hover:bg-slate-100"
>
{isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow}
</Button>
<Button
variant="outline"
asChild
className="h-14 rounded-full border-white/20 bg-white/5 px-7 text-base text-white hover:bg-white/10 dark:border-white/20 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
>
<Link to="/terms">{t.landing.actions.readTerms}</Link>
</Button>
</div>
</div>
</div> </div>
</section> </section>
</div> </div>

48
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useNavigate } from "react-router-dom"
import { ArrowLeft, ArrowRight, Home } from "lucide-react"
import { Button } from "../components/ui/button"
import { useTranslation } from "../hooks/useTranslation"
export default function NotFound() {
const navigate = useNavigate()
const { lang } = useTranslation()
const isFa = lang === "fa"
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_42%,#eef2ff_100%)] px-4 text-center text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_44%,#020617_100%)] dark:text-slate-50">
<section className="mx-auto max-w-2xl">
<div className="text-[7rem] font-semibold leading-none tracking-[-0.08em] text-slate-950 sm:text-[10rem] dark:text-white">
404
</div>
<h1 className="mt-5 text-3xl font-semibold tracking-[-0.04em] sm:text-5xl">
{isFa ? "صفحه پیدا نشد" : "Page not found"}
</h1>
<p className="mx-auto mt-5 max-w-xl text-base leading-8 text-slate-600 sm:text-lg dark:text-slate-300">
{isFa
? "این صفحه وجود ندارد یا آدرس آن تغییر کرده است."
: "This page does not exist or its address has changed."}
</p>
<div className="mt-9 flex flex-col justify-center gap-3 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
className="h-14 rounded-full border-slate-200 bg-white/80 px-7 text-base text-slate-800 backdrop-blur hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
>
{isFa ? <ArrowRight className="me-2 h-4 w-4" /> : <ArrowLeft className="me-2 h-4 w-4" />}
{isFa ? "بازگشت" : "Go back"}
</Button>
<Button
type="button"
onClick={() => navigate("/")}
className="h-14 rounded-full bg-slate-950 px-7 text-base text-white hover:bg-slate-800 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
>
<Home className="me-2 h-4 w-4" />
{isFa ? "صفحه اصلی" : "Home page"}
</Button>
</div>
</section>
</main>
)
}

View File

@@ -12,10 +12,11 @@ import { Button } from "../components/ui/button"
import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud, X, Check } from "lucide-react" import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud, X, Check } from "lucide-react"
import JalaliDatePicker from "../components/ui/JalaliDatePicker" import JalaliDatePicker from "../components/ui/JalaliDatePicker"
import { toast } from "sonner" import { toast } from "sonner"
import { Modal } from "../components/Modal" import { Modal } from "../components/Modal"
import { Input } from "../components/ui/input" import { Input } from "../components/ui/input"
import { TextAreaInput } from "../components/ui/TextAreaInput" import { TextAreaInput } from "../components/ui/TextAreaInput"
import { AuthPasswordField } from "./auth/AuthPasswordField" import { AuthPasswordField } from "./auth/AuthPasswordField"
import { getPasswordValidationMessage } from "./auth/utils"
export interface UserProfile { export interface UserProfile {
id?: string; id?: string;
@@ -187,12 +188,23 @@ export default function Profile() {
return return
} }
if (passwordForm.newPassword !== passwordForm.confirmPassword) { if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error(t.login.passwordMismatch) toast.error(t.login.passwordMismatch)
return return
} }
setIsSaving(true) const passwordValidationMessage = getPasswordValidationMessage(passwordForm.newPassword, t.login)
if (passwordValidationMessage) {
toast.error(passwordValidationMessage)
return
}
if (passwordForm.currentPassword === passwordForm.newPassword) {
toast.error(t.login.passwordReuse)
return
}
setIsSaving(true)
try { try {
await changePassword( await changePassword(
passwordForm.currentPassword, passwordForm.currentPassword,

View File

@@ -1,189 +0,0 @@
import { useEffect, useState } from "react";
import { useBlocker, useNavigate } from "react-router-dom";
import { Briefcase, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { getClients } from "../api/clients";
import { createProject } from "../api/projects";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select";
import { TextAreaInput } from "../components/ui/TextAreaInput";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions";
const COLORS = [
"#3B82F6",
"#10B981",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#EC4899",
"#14B8A6",
"#64748B",
];
export default function ProjectCreate() {
const navigate = useNavigate();
const { t } = useTranslation();
const { activeWorkspace } = useWorkspace();
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState(COLORS[0]);
const [client, setClient] = useState("");
const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || client !== "" || color !== COLORS[0];
useEffect(() => {
if (activeWorkspace && !canCreateProject) {
toast.error("You do not have permission to create projects.");
navigate("/projects");
}
}, [activeWorkspace, canCreateProject, navigate]);
useBlocker(({ currentLocation, nextLocation }) => {
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
}
return false;
});
useEffect(() => {
if (!activeWorkspace?.id) return;
setName("");
setDescription("");
setColor(COLORS[0]);
setClient("");
setClientsList([]);
setIsLoadingData(true);
const loadInitialData = async () => {
try {
const clientsRes = await getClients(activeWorkspace.id);
setClientsList(clientsRes.results || []);
} catch {
toast.error(t.projects?.clientFetchError || "Failed to load clients.");
} finally {
setIsLoadingData(false);
}
};
void loadInitialData();
}, [activeWorkspace?.id, t.projects?.clientFetchError]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !activeWorkspace) return;
try {
setIsSaving(true);
const newProject = await createProject({
workspace: activeWorkspace.id,
name,
description,
color,
client: client || null,
is_archived: false,
});
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
toast.success(t.projects?.createSuccess || "Project created successfully.");
navigate("/projects");
} catch (error: any) {
toast.error(error.message || t.projects?.createError || "Failed to create project.");
} finally {
setIsSaving(false);
}
};
if (!activeWorkspace) return null;
return (
<div className="absolute inset-0 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
{t.projects?.createNew || "Create New Project"}
</h1>
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<form onSubmit={handleSubmit} className="space-y-6 p-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.projects?.namePlaceholder || "Project name..."}
required
/>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{COLORS.map((paletteColor) => (
<button
key={paletteColor}
type="button"
onClick={() => setColor(paletteColor)}
className={`h-5 w-5 shrink-0 rounded-full transition-all duration-150 ${
color === paletteColor
? "scale-110 ring-2 ring-blue-500 ring-offset-2 ring-offset-white shadow-md dark:ring-offset-slate-900"
: "shadow-sm hover:scale-110"
}`}
style={{ backgroundColor: paletteColor }}
aria-label={`Select color ${paletteColor}`}
/>
))}
</div>
</div>
<div>
<label className="mb-2 flex items-center gap-2 text-slate-700 dark:text-slate-300">
<Briefcase size={16} />
{t.projects?.client || "Client"}
</label>
<Select
value={client}
onChange={setClient}
options={[
{ value: "", label: t.projects?.noClient || "No Client" },
...clientsList.map((item) => ({ value: item.id, label: item.name })),
]}
isLoading={isLoadingData}
className="w-full"
buttonClassName="w-full"
/>
</div>
<div>
<label className="mb-2 block text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || "Description"}
</label>
<TextAreaInput
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
rows={5}
/>
</div>
<div className="flex justify-end gap-3 border-t border-slate-200 pt-4 dark:border-slate-700">
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
{t.cancel || "Cancel"}
</Button>
<Button type="submit" disabled={isSaving || !name.trim()}>
{isSaving ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
{t.create || "Create"}
</Button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,200 +0,0 @@
import { useEffect, useState } from "react";
import { useBlocker, useNavigate, useParams } from "react-router-dom";
import { Briefcase, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { getClients } from "../api/clients";
import { getProject, updateProject } from "../api/projects";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select";
import { TextAreaInput } from "../components/ui/TextAreaInput";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions";
const COLORS = [
"#3B82F6",
"#10B981",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#EC4899",
"#14B8A6",
"#64748B",
];
export default function ProjectEdit() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const { activeWorkspace } = useWorkspace();
const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState(COLORS[0]);
const [client, setClient] = useState("");
const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isProjectLoading, setIsProjectLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const hasUnsavedChanges = name.trim() !== "";
useEffect(() => {
if (activeWorkspace && !canEditProject) {
toast.error("You do not have permission to edit projects.");
navigate("/projects");
}
}, [activeWorkspace, canEditProject, navigate]);
useBlocker(({ currentLocation, nextLocation }) => {
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
}
return false;
});
useEffect(() => {
if (!activeWorkspace?.id || !id) return;
const loadInitialData = async () => {
try {
const [clientsRes, projectRes] = await Promise.all([
getClients(activeWorkspace.id),
getProject(id),
]);
setClientsList(clientsRes.results || []);
setName(projectRes.name || "");
setDescription(projectRes.description || "");
setColor(projectRes.color || COLORS[0]);
setClient(projectRes.client?.id || projectRes.client || "");
} catch {
toast.error("Failed to load project data.");
navigate("/projects");
} finally {
setIsLoadingData(false);
setIsProjectLoading(false);
}
};
void loadInitialData();
}, [activeWorkspace?.id, id, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !activeWorkspace || !id) return;
try {
setIsSaving(true);
const updatedProject = await updateProject(id, {
name,
description,
color,
client: client || null,
});
window.dispatchEvent(new CustomEvent("project_updated", { detail: updatedProject }));
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
navigate("/projects");
} catch (error: any) {
toast.error(error.message || t.projects?.updateError || "Failed to update project.");
} finally {
setIsSaving(false);
}
};
if (!activeWorkspace) return null;
if (isProjectLoading) {
return (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
);
}
return (
<div className="absolute inset-0 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
{t.projects?.edit || "Edit Project"}
</h1>
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<form onSubmit={handleSubmit} className="space-y-6 p-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.projects?.namePlaceholder || "Project name..."}
required
/>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{COLORS.map((paletteColor) => (
<button
key={paletteColor}
type="button"
onClick={() => setColor(paletteColor)}
className={`h-5 w-5 shrink-0 rounded-full transition-all duration-150 ${
color === paletteColor
? "scale-110 ring-2 ring-blue-500 ring-offset-2 ring-offset-white shadow-md dark:ring-offset-slate-900"
: "shadow-sm hover:scale-110"
}`}
style={{ backgroundColor: paletteColor }}
aria-label={`Select color ${paletteColor}`}
/>
))}
</div>
</div>
<div>
<label className="mb-2 flex items-center gap-2 text-slate-700 dark:text-slate-300">
<Briefcase size={16} />
{t.projects?.client || "Client"}
</label>
<Select
value={client}
onChange={setClient}
options={[
{ value: "", label: t.projects?.noClient || "No Client" },
...clientsList.map((item) => ({ value: item.id, label: item.name })),
]}
isLoading={isLoadingData}
className="w-full"
buttonClassName="w-full"
/>
</div>
<div>
<label className="mb-2 block text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || "Description"}
</label>
<TextAreaInput
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
rows={5}
/>
</div>
<div className="flex justify-end gap-3 border-t border-slate-200 pt-4 dark:border-slate-700">
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
{t.cancel || "Cancel"}
</Button>
<Button type="submit" disabled={isSaving || !name.trim()}>
{isSaving ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
{t.save || "Save"}
</Button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { getProjects, deleteProject, type Project } from "../api/projects"; import { getProjects, deleteProject, type Project } from "../api/projects";
@@ -7,8 +7,9 @@ import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { ProjectCreateModal } from "../components/projects/ProjectCreateModal"; import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
import { ProjectEditModal } from "../components/projects/ProjectEditModal"; import { ProjectEditModal } from "../components/projects/ProjectEditModal";
import { ProjectAccessModal } from "../components/projects/ProjectAccessModal";
import { Pagination } from "../components/Pagination"; import { Pagination } from "../components/Pagination";
import { Plus, Archive, Building2, Pencil, Trash2, X } from "lucide-react"; import { Plus, Building2, Pencil, ShieldCheck, Trash2, X } from "lucide-react";
import EmptyStateCard from "../components/EmptyStateCard"; import EmptyStateCard from "../components/EmptyStateCard";
import FilterBar from "../components/FilterBar"; import FilterBar from "../components/FilterBar";
@@ -18,6 +19,7 @@ import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { toast } from "sonner"; import { toast } from "sonner";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { MultiSearchableSelect } from "../components/ui/MultiSearchableSelect";
import { import {
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECTS_CREATE, PROJECTS_CREATE,
@@ -47,6 +49,7 @@ export const Projects: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null); const [editingProject, setEditingProject] = useState<Project | null>(null);
const [isAccessModalOpen, setIsAccessModalOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const search = useMemo(() => readStringParam(searchParams, "search", ""), [searchParams]); const search = useMemo(() => readStringParam(searchParams, "search", ""), [searchParams]);
@@ -146,7 +149,8 @@ export const Projects: React.FC = () => {
}; };
}, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]); }, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
const confirmDelete = async () => { const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!deleteModal.project) return; if (!deleteModal.project) return;
try { try {
const deletedId = deleteModal.project.id; const deletedId = deleteModal.project.id;
@@ -186,13 +190,16 @@ export const Projects: React.FC = () => {
return [...selected, ...unselected]; return [...selected, ...unselected];
}, [clients, selectedClientIdsKey]); }, [clients, selectedClientIdsKey]);
const toggleClientFilter = (clientId: string) => { const clientOptions = useMemo(
const nextClientIds = selectedClientIds.includes(clientId) () =>
? selectedClientIds.filter((id) => id !== clientId) sortedClients.map((client) => ({
: [...selectedClientIds, clientId]; value: client.id,
label: client.name,
})),
[sortedClients],
);
updateListParams({ clients: nextClientIds, page: 1 }); const hasActiveProjectFilters = selectedClientIds.length > 0 || isArchived;
};
const updateListParams = ( const updateListParams = (
updates: Record<string, string | number | boolean | null | undefined | string[]>, updates: Record<string, string | number | boolean | null | undefined | string[]>,
@@ -231,14 +238,14 @@ export const Projects: React.FC = () => {
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.projects?.description(activeWorkspace.name) || 'Manage your projects'}</p> <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.projects?.description(activeWorkspace.name) || 'Manage your projects'}</p>
</div> </div>
<div className="flex w-full items-center gap-3 sm:w-auto"> <div className="flex w-full items-center gap-3 sm:w-auto">
{canArchiveProject && ( {canEditProject && (
<Button <Button
variant={isArchived ? "default" : "secondary"} variant="secondary"
onClick={() => updateListParams({ archived: !isArchived, page: 1 })} onClick={() => setIsAccessModalOpen(true)}
className="flex-1 gap-2 shadow-sm sm:flex-none" className="flex-1 gap-2 shadow-sm sm:flex-none"
> >
<Archive className="h-4 w-4" /> <ShieldCheck className="h-4 w-4" />
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')} {t.projects?.manageAccess || "Manage access"}
</Button> </Button>
)} )}
{canCreateProject && ( {canCreateProject && (
@@ -265,63 +272,81 @@ export const Projects: React.FC = () => {
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'} searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
/> />
<div className="mt-4 flex items-center justify-between gap-3"> <div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500"> <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] lg:flex-1">
{t.projects?.filterClients || "Filter by client"} <div className="space-y-2">
</div> <div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{selectedClientIds.length > 0 ? ( {t.projects?.filterClients || "Filter by client"}
<button </div>
type="button" <MultiSearchableSelect
onClick={() => { values={selectedClientIds}
updateListParams({ clients: [], page: 1 }); onChange={(values) => updateListParams({ clients: values, page: 1 })}
}} options={clientOptions}
className="text-xs font-medium text-slate-500 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white" placeholder={t.reports?.allClients || "All clients"}
> searchPlaceholder={t.reports?.searchClients || "Search clients..."}
{t.projects?.clearClientFilters || "Clear filters"} emptyLabel={t.clients?.noClients || "No clients found"}
</button> renderValue={(selectedOptions) => {
) : null} if (selectedOptions.length === 0) {
</div> return t.reports?.allClients || "All clients";
}
if (selectedOptions.length <= 2) {
return selectedOptions.map((option) => option.label).join(", ");
}
return `${selectedOptions[0]?.label} +${selectedOptions.length - 1}`;
}}
buttonClassName="min-h-11 w-full rounded-xl border-slate-200 bg-slate-50/80 dark:border-slate-700"
/>
</div>
<div className="mt-3 overflow-x-auto pb-2"> {canArchiveProject ? (
<div className="flex min-w-max items-center gap-2"> <div className="space-y-2">
{sortedClients.map((client) => { <div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
const isSelected = selectedClientIds.includes(client.id); {t.projects?.archived || "Archived Projects"}
return ( </div>
<button <button
key={client.id}
type="button" type="button"
onClick={() => toggleClientFilter(client.id)} role="switch"
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-medium transition ${ aria-checked={isArchived}
isSelected aria-label={t.projects?.archived || "Archived Projects"}
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300" onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800" className={`inline-flex min-h-11 w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-sm font-medium transition md:w-auto ${
isArchived
? "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300"
: "border-slate-200 bg-slate-50/80 text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white"
}`} }`}
> >
<span className="whitespace-nowrap">{client.name}</span> <span>{t.projects?.archived || "Archived Projects"}</span>
{isSelected ? ( <span
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition ${
isArchived ? "bg-amber-500 dark:bg-amber-400" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span <span
role="button" className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
tabIndex={0} isArchived ? "translate-x-5 rtl:-translate-x-5" : "translate-x-0.5 rtl:-translate-x-0.5"
onClick={(event) => { }`}
event.stopPropagation(); />
toggleClientFilter(client.id); </span>
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
toggleClientFilter(client.id);
}
}}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200"
>
<X className="h-3 w-3" />
</span>
) : null}
</button> </button>
); </div>
})} ) : null}
</div> </div>
<button
type="button"
onClick={() => {
updateListParams({ clients: [], archived: false, page: 1 });
}}
disabled={!hasActiveProjectFilters}
aria-label={t.projects?.clearClientFilters || "Clear filters"}
className={`inline-flex h-10 w-10 items-center justify-center self-start rounded-xl border text-sm transition lg:self-end ${
hasActiveProjectFilters
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
: "border-slate-200 bg-white text-slate-400 opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500"
} disabled:cursor-not-allowed`}
>
<X className="h-4 w-4" />
</button>
</div> </div>
</div> </div>
@@ -350,9 +375,15 @@ export const Projects: React.FC = () => {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div <div
className="h-10 w-10 shrink-0 rounded-xl border border-slate-200 dark:border-slate-700" className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-slate-200 text-sm font-semibold text-white dark:border-slate-700"
style={{ backgroundColor: project.color || "#3B82F6" }} style={{ backgroundColor: project.color || "#3B82F6" }}
/> >
{project.thumbnail ? (
<img src={project.thumbnail} alt={project.name} className="h-full w-full object-cover" />
) : (
project.name.trim().charAt(0).toUpperCase() || "P"
)}
</div>
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle> <CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400"> <div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
@@ -438,6 +469,57 @@ export const Projects: React.FC = () => {
/> />
)} )}
{canEditProject && (
<ProjectAccessModal
isOpen={isAccessModalOpen}
onClose={() => setIsAccessModalOpen(false)}
workspaceId={activeWorkspace.id}
onApplied={() => {
void fetchProjectList();
}}
labels={{
title: t.projects?.accessModalTitle || "Project access",
description: t.projects?.accessModalDescription || "Grant or revoke project access for workspace members.",
close: t.actions?.cancel || "Close",
member: t.projects?.accessMemberLabel || "Member",
projects: t.sidebar?.projects || "Projects",
loading: t.loading || "Loading...",
noMembers: t.projects?.accessNoMembers || "No eligible members were found.",
noProjects: t.projects?.accessNoProjects || "No projects found.",
searchPlaceholder: t.projects?.searchPlaceholder || "Search projects...",
allClients: t.reports?.allClients || "All clients",
selectAllVisible: t.projects?.accessSelectVisible || "Select all visible",
clearSelection: t.projects?.accessClearSelection || "Clear selection",
selectClientProjects: t.projects?.accessSelectClientProjects || "Select all projects for client",
grantSelected: t.projects?.accessGrant || "Grant selected",
revokeSelected: t.projects?.accessRevoke || "Revoke selected",
accessGranted: t.projects?.accessGrantSuccess || "Project access granted.",
accessRevoked: t.projects?.accessRevokeSuccess || "Project access revoked.",
memberRole: t.workspace?.roleLabel || "Role",
client: t.projects?.clientLabel || "Client",
noClient: t.projects?.noClient || "No client",
accessOn: t.projects?.accessOn || "Has access",
accessOff: t.projects?.accessOff || "No access",
loadError: t.projects?.accessLoadError || "Failed to load project access state.",
saveError: t.projects?.accessSaveError || "Failed to update project access.",
workspaceRate: t.rates?.workspaceRate || "Workspace rate",
projectOverride: t.rates?.projectOverride || "Project override",
inheritsWorkspaceRate: t.rates?.inheritsWorkspaceRate || "Inherits workspace rate",
noRate: t.rates?.noRate || "No rate",
hourlyRatePlaceholder: t.rates?.hourlyRatePlaceholder || "0.00",
currencyPlaceholder: t.rates?.currencyPlaceholder || "USD",
removeRate: t.rates?.removeRate || "Remove rate",
projectRateSaved: t.rates?.projectSaveSuccess || "Project user rate saved.",
projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.",
projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.",
projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.",
implicitAccessHint:
t.projects?.implicitAccessHint ||
"Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
}}
/>
)}
{deleteModal.project && ( {deleteModal.project && (
<Modal <Modal
isOpen={deleteModal.isOpen} isOpen={deleteModal.isOpen}
@@ -462,7 +544,8 @@ export const Projects: React.FC = () => {
<Button <Button
variant="destructive" variant="destructive"
disabled={deleteInput !== deleteModal.project.name} disabled={deleteInput !== deleteModal.project.name}
onClick={confirmDelete} type="submit"
form="delete-project-form"
className="rounded-xl font-semibold" className="rounded-xl font-semibold"
> >
{t.actions?.delete || 'Delete'} {t.actions?.delete || 'Delete'}
@@ -470,7 +553,7 @@ export const Projects: React.FC = () => {
</> </>
} }
> >
<div className="flex flex-col gap-4"> <form id="delete-project-form" onSubmit={confirmDelete} className="flex flex-col gap-4">
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed"> <p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong> {t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
</p> </p>
@@ -481,7 +564,7 @@ export const Projects: React.FC = () => {
onChange={(e) => setDeleteInput(e.target.value)} onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.project.name} placeholder={deleteModal.project.name}
/> />
</div> </form>
</Modal> </Modal>
)} )}

View File

@@ -10,10 +10,12 @@ import {
getChartReport, getChartReport,
getDayDetailsReport, getDayDetailsReport,
getTableReport, getTableReport,
getUserSummaryReport,
type ChartReportResponse, type ChartReportResponse,
type DayDetailsResponse, type DayDetailsResponse,
type ReportFilters, type ReportFilters,
type TableReportResponse, type TableReportResponse,
type UserScopedTableReport,
} from "../api/reports"; } from "../api/reports";
import { getTags, type Tag } from "../api/tags"; import { getTags, type Tag } from "../api/tags";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces"; import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces";
@@ -262,6 +264,13 @@ export default function Reports() {
} }
}; };
const handleLoadUserSummaryReport = async (userId: string): Promise<UserScopedTableReport> => {
if (!apiFilters) {
throw new Error("Missing report filters");
}
return getUserSummaryReport(apiFilters, userId);
};
if (!activeWorkspace) { if (!activeWorkspace) {
return ( return (
<div className="p-6"> <div className="p-6">
@@ -362,13 +371,10 @@ export default function Reports() {
}} }}
/> />
{isLoading ? ( {tab === "chart" ? (
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.loading || "Loading..."}
</div>
) : tab === "chart" ? (
<ReportsChartPanel <ReportsChartPanel
data={chartData} data={chartData}
isLoading={isLoading}
labels={{ labels={{
totalHours: t.reports?.totalHours || "Total hours", totalHours: t.reports?.totalHours || "Total hours",
billableHours: t.reports?.billableHours || "Billable hours", billableHours: t.reports?.billableHours || "Billable hours",
@@ -376,6 +382,7 @@ export default function Reports() {
totalIncome: t.reports?.totalIncome || "Total income", totalIncome: t.reports?.totalIncome || "Total income",
chart: t.reports?.chartTitle || "Activity chart", chart: t.reports?.chartTitle || "Activity chart",
totalSeconds: t.reports?.totalSeconds || "Total seconds", totalSeconds: t.reports?.totalSeconds || "Total seconds",
loading: t.loading || "Loading...",
}} }}
/> />
) : ( ) : (
@@ -384,8 +391,10 @@ export default function Reports() {
dayDetails={dayDetails} dayDetails={dayDetails}
openDay={openDay} openDay={openDay}
onToggleDay={(day) => void handleToggleDay(day)} onToggleDay={(day) => void handleToggleDay(day)}
onLoadUserSummaryReport={(userId) => handleLoadUserSummaryReport(userId)}
onExport={(type) => void handleExport(type)} onExport={(type) => void handleExport(type)}
exportState={exportState} exportState={exportState}
isLoading={isLoading}
labels={{ labels={{
exportExcel: t.reports?.exportExcel || "Export Excel", exportExcel: t.reports?.exportExcel || "Export Excel",
exportPdf: t.reports?.exportPdf || "Export PDF", exportPdf: t.reports?.exportPdf || "Export PDF",
@@ -398,10 +407,30 @@ export default function Reports() {
details: t.reports?.details || "Details", details: t.reports?.details || "Details",
total: t.reports?.total || "Total", total: t.reports?.total || "Total",
name: t.reports?.name || "Name", name: t.reports?.name || "Name",
mobile: t.reports?.mobile || "Mobile",
hourlyRates: t.reports?.hourlyRates || "Hourly rates",
workingHours: t.reports?.workingHours || "Working hours",
nonWorkingHours: t.reports?.nonWorkingHours || "Non-working hours",
projectPercentages: t.reports?.projectPercentages || "Project percentages",
clientPercentages: t.reports?.clientPercentages || "Client percentages",
tagPercentages: t.reports?.tagPercentages || "Tag percentages",
userSummaryTitle: t.reports?.userSummaryTitle || "Summary by user",
userSummaryDetailsTitle: t.reports?.userSummaryDetailsTitle || "User details: {name}",
userSummaryDetailsDescription: t.reports?.userSummaryDetailsDescription || "Detailed rate history and distribution for the selected user.",
rateHistory: t.reports?.rateHistory || "Rate history",
project: t.reports?.project || "Project",
fromDate: t.reports?.fromDate || "From",
toDate: t.reports?.toDate || "To",
now: t.reports?.now || "Now",
loadDetailsError: t.reports?.loadError || "Failed to load user report details.",
hourPercentage: t.reports?.hourPercentage || "Hour %",
incomePercentage: t.reports?.incomePercentage || "Income %",
noData: t.reports?.noData || "No data",
clientsTable: t.reports?.clientsTable || "Clients", clientsTable: t.reports?.clientsTable || "Clients",
projectsTable: t.reports?.projectsTable || "Projects", projectsTable: t.reports?.projectsTable || "Projects",
tagsTable: t.reports?.tagsTable || "Tags", tagsTable: t.reports?.tagsTable || "Tags",
noDescription: t.timesheet?.emptyDescription || "No description", noDescription: t.timesheet?.emptyDescription || "No description",
loading: t.loading || "Loading...",
}} }}
/> />
)} )}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react"; import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -105,7 +105,8 @@ export default function Tags() {
setFormColor(DEFAULT_COLOR); setFormColor(DEFAULT_COLOR);
}; };
const handleSubmit = async () => { const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!activeWorkspace?.id || !formName.trim()) return; if (!activeWorkspace?.id || !formName.trim()) return;
try { try {
@@ -284,13 +285,13 @@ export default function Tags() {
<Button variant="secondary" onClick={closeModal}> <Button variant="secondary" onClick={closeModal}>
{t.actions?.cancel || "Cancel"} {t.actions?.cancel || "Cancel"}
</Button> </Button>
<Button onClick={() => void handleSubmit()} disabled={isSaving || !formName.trim()}> <Button type="submit" form="tag-form" disabled={isSaving || !formName.trim()}>
{isSaving ? "..." : (editingTag ? (t.save || "Save") : (t.create || "Create"))} {isSaving ? "..." : (editingTag ? (t.save || "Save") : (t.create || "Create"))}
</Button> </Button>
</> </>
} }
> >
<div className="space-y-4"> <form id="tag-form" onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t.tags?.nameLabel || "Tag name"} {t.tags?.nameLabel || "Tag name"}
@@ -304,7 +305,7 @@ export default function Tags() {
</label> </label>
<input type="color" value={formColor} onChange={(event) => setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" /> <input type="color" value={formColor} onChange={(event) => setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" />
</div> </div>
</div> </form>
</Modal> </Modal>
{deleteModal.tag && ( {deleteModal.tag && (
@@ -320,21 +321,28 @@ export default function Tags() {
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => { type="submit"
if (!deleteModal.tag) return; form="delete-tag-form"
void handleDelete(deleteModal.tag);
setDeleteModal({ isOpen: false, tag: null });
}}
> >
{t.actions?.delete || "Delete"} {t.actions?.delete || "Delete"}
</Button> </Button>
</> </>
} }
> >
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <form
{(t.tags?.deleteConfirmMessage as ((name: string) => string) | undefined)?.(deleteModal.tag.name) || id="delete-tag-form"
`Are you sure you want to delete "${deleteModal.tag.name}"?`} onSubmit={(event) => {
</p> event.preventDefault();
if (!deleteModal.tag) return;
void handleDelete(deleteModal.tag);
setDeleteModal({ isOpen: false, tag: null });
}}
>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{(t.tags?.deleteConfirmMessage as ((name: string) => string) | undefined)?.(deleteModal.tag.name) ||
`Are you sure you want to delete "${deleteModal.tag.name}"?`}
</p>
</form>
</Modal> </Modal>
)} )}
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@ import {
import { useAppContext } from '../context/AppContext'; import { useAppContext } from '../context/AppContext';
import { useWorkspace } from '../context/WorkspaceContext'; import { useWorkspace } from '../context/WorkspaceContext';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
import { formatRateDisplay } from '../lib/money';
import { import {
CLIENTS_VIEW, CLIENTS_VIEW,
PROJECTS_VIEW, PROJECTS_VIEW,
@@ -188,14 +189,17 @@ export default function WorkspaceDetail() {
return `${firstName}${lastName}`.trim().toUpperCase() || getMemberName(member).charAt(0).toUpperCase(); return `${firstName}${lastName}`.trim().toUpperCase() || getMemberName(member).charAt(0).toUpperCase();
}; };
const formatRateUnit = (rate?: WorkspaceUserRate) => { const formatRateUnit = (rate?: WorkspaceUserRate) =>
if (!rate) return t.rates?.noRate || 'No rate'; rate
const unitLabel = ? formatRateDisplay(
lang === 'fa' {
? rate.price_unit?.local_name || rate.price_unit?.code || rate.currency hourly_rate: rate.hourly_rate,
: rate.price_unit?.code || rate.currency; currency: rate.currency,
return `${rate.hourly_rate} ${unitLabel}`; price_unit: rate.price_unit,
}; },
lang,
)
: (t.rates?.noRate || 'No rate');
const workspaceRole = workspace?.my_role; const workspaceRole = workspace?.my_role;
const canEdit = canWorkspace(workspaceRole, WORKSPACE_EDIT); const canEdit = canWorkspace(workspaceRole, WORKSPACE_EDIT);
@@ -273,38 +277,6 @@ export default function WorkspaceDetail() {
{workspace.description || t.workspace?.noDescription} {workspace.description || t.workspace?.noDescription}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
{canViewReports && (
<button
onClick={() => openWorkspaceRoute('/reports')}
className="inline-flex w-full h-11 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700"
>
{t.workspace?.openReports || 'Open reports'}
</button>
)}
<div className="flex w-full items-center gap-2">
{canEdit && (
<button
onClick={() => navigate(`/workspaces/${id}/edit`)}
className="inline-flex w-full h-11 items-center justify-center gap-2 rounded-xl border px-4 text-sm font-semibold transition bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400 border-blue-200 hover:border-blue-300 hover:bg-blue-100 dark:border-blue-900/60 dark:hover:bg-blue-900/30"
>
<Edit2 className="h-4 w-4" />
<span className="lg:hidden">{t.actions?.edit || 'Edit'}</span>
</button>
)}
{canDelete && (
<button
onClick={handleDelete}
className="inline-flex w-full h-11 items-center justify-center gap-2 rounded-xl border border-red-200 bg-red-50 px-4 text-sm font-semibold text-red-700 transition hover:border-red-300 hover:bg-red-100 dark:border-red-900/60 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30"
>
<Trash2 className="h-4 w-4" />
<span className="lg:hidden">{t.actions?.delete || 'Delete'}</span>
</button>
)}
</div>
</div>
</div> </div>
</section> </section>
@@ -389,9 +361,10 @@ export default function WorkspaceDetail() {
{canEdit && ( {canEdit && (
<button <button
onClick={() => navigate(`/workspaces/${id}/edit`)} onClick={() => navigate(`/workspaces/${id}/edit`)}
className="inline-flex h-10 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700" className="inline-flex gap-2 h-10 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700"
> >
{t.workspace?.manageMembers || 'Manage members'} {t.workspace?.manageMembers || 'Manage members'}
<Edit2 className="h-4 w-4" />
</button> </button>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react';
import { useBlocker, useNavigate, useParams } from 'react-router-dom'; import { useBlocker, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
import { AlertCircle, UserPlus, Trash2, Shield, UploadCloud } from 'lucide-react'; import { AlertCircle, ShieldCheck, UserPlus, Trash2, Shield, UploadCloud } from 'lucide-react';
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from '@headlessui/react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates'; import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates';
@@ -29,6 +29,7 @@ import { Select } from '../components/ui/Select';
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
import { TextAreaInput } from '../components/ui/TextAreaInput'; import { TextAreaInput } from '../components/ui/TextAreaInput';
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields'; import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
import { ProjectAccessModal } from '../components/projects/ProjectAccessModal';
const toEnglishDigits = (str: string) => { const toEnglishDigits = (str: string) => {
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString()) return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
@@ -82,6 +83,7 @@ export default function EditWorkspace() {
// Modal State // Modal State
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null); const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -115,16 +117,16 @@ export default function EditWorkspace() {
setThumbnailFile(null); setThumbnailFile(null);
return; return;
} }
const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP."); toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return; return;
} }
const maxBytes = 2 * 1024 * 1024; const maxBytes = 2 * 1024 * 1024;
if (file.size > maxBytes) { if (file.size > maxBytes) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less."); toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return; return;
} }
setThumbnailFile(file); setThumbnailFile(file);
setClearThumbnail(false); setClearThumbnail(false);
}; };
@@ -338,8 +340,9 @@ export default function EditWorkspace() {
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>; if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
return ( return (
<div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6"> <>
<div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0"> <h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
{t.workspace?.editTitle || "Edit Workspace"} {t.workspace?.editTitle || "Edit Workspace"}
</h1> </h1>
@@ -372,7 +375,7 @@ export default function EditWorkspace() {
</div> </div>
<div className="mb-6"> <div className="mb-6">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"> <label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"} {t.workspace?.thumbnailLabel || "Thumbnail"}
</label> </label>
<label className="mt-3 flex aspect-square w-full cursor-pointer items-center justify-center overflow-hidden rounded-xl border border-dashed border-slate-300 bg-slate-100 text-5xl font-semibold text-slate-700 transition hover:bg-slate-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"> <label className="mt-3 flex aspect-square w-full cursor-pointer items-center justify-center overflow-hidden rounded-xl border border-dashed border-slate-300 bg-slate-100 text-5xl font-semibold text-slate-700 transition hover:bg-slate-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700">
@@ -392,9 +395,9 @@ export default function EditWorkspace() {
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<UploadCloud className="h-10 w-10 text-slate-500 dark:text-slate-400" /> <UploadCloud className="h-10 w-10 text-slate-500 dark:text-slate-400" />
<span className="text-sm font-medium text-slate-500 dark:text-slate-400"> <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
{t.workspace?.uploadImage || "Click to upload image"} {t.workspace?.uploadImage || "Click to upload image"}
</span> </span>
</div> </div>
)} )}
@@ -415,9 +418,9 @@ export default function EditWorkspace() {
}} }}
className="mt-2 text-xs text-red-600 hover:underline dark:text-red-400" className="mt-2 text-xs text-red-600 hover:underline dark:text-red-400"
> >
{t.workspace?.removeImage || "Remove image"} {t.workspace?.removeImage || "Remove image"}
</button> </button>
)} )}
</div> </div>
<div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0"> <div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0">
<Button type="button" variant="ghost" onClick={() => navigate('/workspaces')}> <Button type="button" variant="ghost" onClick={() => navigate('/workspaces')}>
@@ -432,9 +435,30 @@ export default function EditWorkspace() {
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden"> <div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10"> <div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4"> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{ t.workspace?.members || "Members" } <h2 className="text-lg font-semibold text-slate-900 dark:text-white">
</h2> { t.workspace?.members || "Members" }
</h2>
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<Button
type="button"
variant="secondary"
onClick={() => setIsProjectAccessModalOpen(true)}
className="gap-2 self-start sm:self-auto"
>
<ShieldCheck className="h-4 w-4" />
{t.projects?.manageAccess || "Projects & Rates"}
</Button>
) : null}
</div>
<div className="mb-4 flex items-start gap-3 rounded-xl border border-sky-100 bg-sky-50/80 px-4 py-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-100">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="leading-6">
{t.workspace?.projectRateHint ||
"Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set an override rate for a specific member."}
</p>
</div>
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && ( {canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
<div className="space-y-3"> <div className="space-y-3">
@@ -651,5 +675,57 @@ export default function EditWorkspace() {
</Dialog> </Dialog>
</Transition> </Transition>
</div> </div>
);
} {canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<ProjectAccessModal
isOpen={isProjectAccessModalOpen}
onClose={() => setIsProjectAccessModalOpen(false)}
workspaceId={id}
onApplied={() => {}}
labels={{
title: t.projects?.accessModalTitle || "Projects & Rates",
description:
t.projects?.accessModalDescription ||
"Manage project access for members and guests, and set project-specific rates for any workspace user.",
close: t.actions?.cancel || "Close",
member: t.projects?.accessMemberLabel || "User",
projects: t.sidebar?.projects || "Projects",
loading: t.loading || "Loading...",
noMembers: t.projects?.accessNoMembers || "No workspace users were found.",
noProjects: t.projects?.accessNoProjects || "No projects found.",
searchPlaceholder: t.projects?.searchPlaceholder || "Search projects...",
allClients: t.reports?.allClients || "All clients",
selectAllVisible: t.projects?.accessSelectVisible || "Select all visible",
clearSelection: t.projects?.accessClearSelection || "Clear selection",
selectClientProjects: t.projects?.accessSelectClientProjects || "Select all projects for client",
grantSelected: t.projects?.accessGrant || "Grant selected",
revokeSelected: t.projects?.accessRevoke || "Revoke selected",
accessGranted: t.projects?.accessGrantSuccess || "Project access granted.",
accessRevoked: t.projects?.accessRevokeSuccess || "Project access revoked.",
memberRole: t.workspace?.roleLabel || "Role",
client: t.projects?.clientLabel || "Client",
noClient: t.projects?.noClient || "No client",
accessOn: t.projects?.accessOn || "Has access",
accessOff: t.projects?.accessOff || "No access",
loadError: t.projects?.accessLoadError || "Failed to load project access state.",
saveError: t.projects?.accessSaveError || "Failed to update project access.",
workspaceRate: t.rates?.workspaceRate || "Workspace rate",
projectOverride: t.rates?.projectOverride || "Project override",
inheritsWorkspaceRate: t.rates?.inheritsWorkspaceRate || "Inherits workspace rate",
noRate: t.rates?.noRate || "No rate",
hourlyRatePlaceholder: t.rates?.hourlyRatePlaceholder || "0.00",
currencyPlaceholder: t.rates?.currencyPlaceholder || "USD",
removeRate: t.rates?.removeRate || "Remove rate",
projectRateSaved: t.rates?.projectSaveSuccess || "Project user rate saved.",
projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.",
projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.",
projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.",
implicitAccessHint:
t.projects?.implicitAccessHint ||
"Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
}}
/>
) : null}
</>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { Plus, Trash2, Pencil, Eye, LayoutDashboard } from 'lucide-react'; import { Plus, Trash2, Pencil, Eye, LayoutDashboard } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -94,8 +94,9 @@ export default function Workspaces() {
} }
}; };
const confirmDelete = async () => { const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
if (!deleteModal.workspace) return; event?.preventDefault();
if (!deleteModal.workspace) return;
try { try {
const deletedId = deleteModal.workspace.id; const deletedId = deleteModal.workspace.id;
await deleteWorkspace(deletedId); await deleteWorkspace(deletedId);
@@ -275,15 +276,16 @@ export default function Workspaces() {
<Button <Button
variant="destructive" variant="destructive"
disabled={deleteInput !== deleteModal.workspace.name} disabled={deleteInput !== deleteModal.workspace.name}
onClick={confirmDelete} type="submit"
className="rounded-xl font-semibold" form="delete-workspace-form"
className="rounded-xl font-semibold"
> >
{t.actions?.delete || 'Delete'} {t.actions?.delete || 'Delete'}
</Button> </Button>
</> </>
} }
> >
<div className="flex flex-col gap-4"> <form id="delete-workspace-form" onSubmit={confirmDelete} className="flex flex-col gap-4">
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed"> <p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
{t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.workspace.name}</strong> {t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.workspace.name}</strong>
</p> </p>
@@ -294,7 +296,7 @@ export default function Workspaces() {
onChange={(e) => setDeleteInput(e.target.value)} onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.workspace.name} placeholder={deleteModal.workspace.name}
/> />
</div> </form>
</Modal> </Modal>
)} )}
</div> </div>

View File

@@ -0,0 +1,132 @@
import { useEffect, useMemo, useRef } from "react"
const OTP_LENGTH = 5
const normalizeDigits = (value: string) =>
value
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
const sanitizeOtp = (value: string) => normalizeDigits(value).replace(/\D/g, "").slice(0, OTP_LENGTH)
export function AuthOtpInput({
id,
value,
disabled,
onChange,
onComplete,
}: {
id: string
value: string
disabled?: boolean
onChange: (value: string) => void
onComplete?: (value: string) => void
}) {
const inputRefs = useRef<Array<HTMLInputElement | null>>([])
const lastCompletedValueRef = useRef("")
const normalizedValue = useMemo(() => sanitizeOtp(value), [value])
const digits = useMemo(
() => Array.from({ length: OTP_LENGTH }, (_, index) => normalizedValue[index] ?? ""),
[normalizedValue],
)
useEffect(() => {
if (normalizedValue.length !== OTP_LENGTH) {
lastCompletedValueRef.current = ""
return
}
if (normalizedValue !== lastCompletedValueRef.current) {
lastCompletedValueRef.current = normalizedValue
onComplete?.(normalizedValue)
}
}, [normalizedValue, onComplete])
const focusIndex = (index: number) => {
inputRefs.current[index]?.focus()
inputRefs.current[index]?.select()
}
const handleSlotChange = (index: number, nextRawValue: string) => {
const nextValue = sanitizeOtp(nextRawValue)
if (!nextValue) {
const updated = digits.slice()
updated[index] = ""
onChange(updated.join(""))
return
}
if (nextValue.length > 1) {
onChange(nextValue)
const focusTarget = Math.min(nextValue.length, OTP_LENGTH - 1)
focusIndex(focusTarget)
return
}
const updated = digits.slice()
updated[index] = nextValue
const combined = updated.join("")
onChange(combined)
if (index < OTP_LENGTH - 1) {
focusIndex(index + 1)
}
}
const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Backspace" && !digits[index] && index > 0) {
const updated = digits.slice()
updated[index - 1] = ""
onChange(updated.join(""))
focusIndex(index - 1)
event.preventDefault()
return
}
if (event.key === "ArrowLeft" && index > 0) {
focusIndex(index - 1)
event.preventDefault()
return
}
if (event.key === "ArrowRight" && index < OTP_LENGTH - 1) {
focusIndex(index + 1)
event.preventDefault()
}
}
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
event.preventDefault()
const pasted = sanitizeOtp(event.clipboardData.getData("text"))
if (!pasted) {
return
}
onChange(pasted)
focusIndex(Math.min(pasted.length, OTP_LENGTH - 1))
}
return (
<div className="flex items-center justify-center gap-2 sm:gap-3" dir="ltr">
{digits.map((digit, index) => (
<input
key={`${id}-${index}`}
ref={(element) => {
inputRefs.current[index] = element
}}
id={index === 0 ? id : undefined}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={OTP_LENGTH}
disabled={disabled}
value={digit}
onChange={(event) => handleSlotChange(index, event.target.value)}
onKeyDown={(event) => handleKeyDown(index, event)}
onPaste={handlePaste}
className="h-12 w-12 rounded-2xl border border-slate-200 bg-white text-center text-lg font-semibold tracking-[0.18em] text-slate-900 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:ring-sky-500/20 sm:h-14 sm:w-14"
/>
))}
</div>
)
}

View File

@@ -1,22 +1,19 @@
import { Loader2 } from "lucide-react" import { useMemo } from "react"
import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom" import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { sendOtp } from "../../api/users"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext" import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" import { formatCooldown } from "./utils"
export function ForgotPasswordMobilePage() { export function ForgotPasswordMobilePage() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang } = useTranslation() const { t, lang } = useTranslation()
const { state, setMobile, setCode, setCooldown, clearCooldown } = useAuthFlow() const { state, setMobile, markOtpSendPending, clearOtpDelivery } = useAuthFlow()
const isRtl = lang === "fa" const isRtl = lang === "fa"
const [loading, setLoading] = useState(false)
const alert = useMemo(() => { const alert = useMemo(() => {
if (state.cooldowns.forgotPasswordOtpSend <= 0) { if (state.cooldowns.forgotPasswordOtpSend <= 0) {
@@ -41,28 +38,9 @@ export function ForgotPasswordMobilePage() {
return return
} }
setLoading(true) clearOtpDelivery("forgotPassword")
try { markOtpSendPending("forgotPassword")
await sendOtp(state.forgotPassword.mobile, "forget_password") navigate("/auth/forgot-password/verify")
clearCooldown("forgotPasswordOtpSend")
setCode("forgotPassword", "")
navigate("/auth/forgot-password/verify")
toast.success(t.login.toasts.verifySent)
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "forgotPasswordOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setLoading(false)
}
} }
return ( return (
@@ -78,7 +56,6 @@ export function ForgotPasswordMobilePage() {
type="tel" type="tel"
dir="ltr" dir="ltr"
maxLength={11} maxLength={11}
disabled={loading}
value={state.forgotPassword.mobile} value={state.forgotPassword.mobile}
onChange={(event) => setMobile("forgotPassword", event.target.value)} onChange={(event) => setMobile("forgotPassword", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`} className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
@@ -86,10 +63,9 @@ export function ForgotPasswordMobilePage() {
<Button <Button
onClick={handleContinue} onClick={handleContinue}
disabled={loading || state.cooldowns.forgotPasswordOtpSend > 0} disabled={state.cooldowns.forgotPasswordOtpSend > 0}
className="h-11 w-full" className="h-11 w-full"
> >
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldownLabel || t.login.sendResetCode} {cooldownLabel || t.login.sendResetCode}
</Button> </Button>

View File

@@ -1,55 +1,161 @@
import { useState } from "react" import { Loader2 } from "lucide-react"
import { Link, Navigate, useNavigate } from "react-router-dom" import { useEffect, useMemo, useRef, useState } from "react"
import { Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { sendOtp } from "../../api/users"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext" import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthOtpInput } from "./AuthOtpInput"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { formatCooldown, getApiErrorMessage, getOtpRemainingSeconds, handleThrottleError } from "./utils"
export function ForgotPasswordOtpPage() { export function ForgotPasswordOtpPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation() const { t, lang } = useTranslation()
const { state, setCode } = useAuthFlow() const {
const [loading, setLoading] = useState(false) state,
setCode,
setCooldown,
clearCooldown,
setOtpDelivery,
clearOtpDelivery,
} = useAuthFlow()
const isRtl = lang === "fa"
const autoSendStartedRef = useRef(false)
const [isSendingOtp, setIsSendingOtp] = useState(false)
const [isContinuing, setIsContinuing] = useState(false)
const [now, setNow] = useState(Date.now())
if (!state.forgotPassword.mobile) { if (!state.forgotPassword.mobile) {
return <Navigate to="/auth/forgot-password" replace /> return <Navigate to="/auth/forgot-password" replace />
} }
const handleContinue = async (event: React.FormEvent) => { useEffect(() => {
event.preventDefault() if (!state.forgotPassword.otpExpiresAt || getOtpRemainingSeconds(state.forgotPassword.otpExpiresAt) <= 0) {
return
}
if (!state.forgotPassword.code) { const timer = window.setInterval(() => {
setNow(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [state.forgotPassword.otpExpiresAt])
const sendForgotPasswordOtp = async () => {
setIsSendingOtp(true)
try {
const response = await sendOtp(state.forgotPassword.mobile, "forget_password")
clearCooldown("forgotPasswordOtpSend")
setCode("forgotPassword", "")
setOtpDelivery("forgotPassword", response.expires_in_seconds)
setNow(Date.now())
toast.success(t.login.toasts.verifySent)
} catch (error) {
clearOtpDelivery("forgotPassword")
if (
!handleThrottleError({
error,
cooldownKey: "forgotPasswordOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setIsSendingOtp(false)
}
}
useEffect(() => {
if (!state.forgotPassword.pendingOtpSend || autoSendStartedRef.current) {
return
}
autoSendStartedRef.current = true
void sendForgotPasswordOtp()
}, [state.forgotPassword.pendingOtpSend])
const otpRemainingSeconds = state.forgotPassword.otpExpiresAt
? Math.max(0, Math.ceil((state.forgotPassword.otpExpiresAt - now) / 1000))
: 0
const alert = useMemo(() => {
if (state.cooldowns.forgotPasswordOtpSend <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}, [isRtl, state.cooldowns.forgotPasswordOtpSend, t.login.throttle])
const expiryMessage =
otpRemainingSeconds > 0
? t.login.otpExpiresIn(formatCooldown(otpRemainingSeconds, isRtl))
: state.forgotPassword.otpExpiresAt
? t.login.otpExpired
: null
const continueToReset = async (code: string) => {
if (code.length !== 5) {
toast.error(t.login.toasts.enterOtp) toast.error(t.login.toasts.enterOtp)
return return
} }
setLoading(true) setIsContinuing(true)
setCode("forgotPassword", code)
navigate("/auth/forgot-password/password") navigate("/auth/forgot-password/password")
} }
const isBusy = isSendingOtp || isContinuing
return ( return (
<AuthPanel <AuthPanel
title={t.login.forgotPasswordVerifyTitle} title={t.login.forgotPasswordVerifyTitle}
description={t.login.sentCodeDesc(state.forgotPassword.mobile)} description={t.login.sentCodeDesc(state.forgotPassword.mobile)}
alert={alert}
> >
<form onSubmit={handleContinue} className="grid gap-4"> <form
<Input onSubmit={(event) => {
event.preventDefault()
void continueToReset(state.forgotPassword.code)
}}
className="grid gap-4"
>
<AuthOtpInput
id="forgot-password-otp" id="forgot-password-otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
maxLength={6}
disabled={loading}
value={state.forgotPassword.code} value={state.forgotPassword.code}
onChange={(event) => setCode("forgotPassword", event.target.value)} disabled={isBusy}
className="h-11 text-center text-lg tracking-widest" onChange={(value) => setCode("forgotPassword", value)}
onComplete={(value) => void continueToReset(value)}
/> />
<Button type="submit" className="h-11 w-full" disabled={loading}> {expiryMessage ? (
{t.login.continueToResetPassword} <p className="text-center text-sm text-slate-500 dark:text-slate-400">{expiryMessage}</p>
) : null}
<Button type="submit" className="h-11 w-full" disabled={isBusy}>
{(isSendingOtp || isContinuing) && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{isSendingOtp ? t.login.sendingOtp : t.login.continueToResetPassword}
</Button>
<Button
type="button"
variant="outline"
className="h-11 w-full"
disabled={isBusy || otpRemainingSeconds > 0 || state.cooldowns.forgotPasswordOtpSend > 0}
onClick={() => void sendForgotPasswordOtp()}
>
{state.cooldowns.forgotPasswordOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl))
: t.login.resendOtp}
</Button> </Button>
</form> </form>
</AuthPanel> </AuthPanel>

View File

@@ -9,7 +9,7 @@ import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { AuthPasswordField } from "./AuthPasswordField" import { AuthPasswordField } from "./AuthPasswordField"
import { getApiErrorMessage } from "./utils" import { getApiErrorMessage, getPasswordValidationMessage } from "./utils"
export function ForgotPasswordPasswordPage() { export function ForgotPasswordPasswordPage() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -40,6 +40,12 @@ export function ForgotPasswordPasswordPage() {
return return
} }
const passwordValidationMessage = getPasswordValidationMessage(password, t.login)
if (passwordValidationMessage) {
toast.error(passwordValidationMessage)
return
}
setLoading(true) setLoading(true)
try { try {
await resetPasswordWithOtp(state.forgotPassword.mobile, state.forgotPassword.code, password, confirmation) await resetPasswordWithOtp(state.forgotPassword.mobile, state.forgotPassword.code, password, confirmation)

View File

@@ -1,15 +1,14 @@
import { Loader2 } from "lucide-react" import { useMemo } from "react"
import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom" import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { sendOtp, startGoogleLogin } from "../../api/users" import { startGoogleLogin } from "../../api/users"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext" import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" import { formatCooldown } from "./utils"
const GoogleIcon = () => ( const GoogleIcon = () => (
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24"> <svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24">
@@ -35,10 +34,8 @@ const GoogleIcon = () => (
export function LoginMobilePage() { export function LoginMobilePage() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang } = useTranslation() const { t, lang } = useTranslation()
const { state, setMobile, setCooldown, clearCooldown, resetFlow } = useAuthFlow() const { state, setMobile, markOtpSendPending, clearOtpDelivery, resetFlow } = useAuthFlow()
const isRtl = lang === "fa" const isRtl = lang === "fa"
const [loading, setLoading] = useState(false)
const alert = useMemo(() => { const alert = useMemo(() => {
if (state.cooldowns.loginOtpSend <= 0) { if (state.cooldowns.loginOtpSend <= 0) {
return null return null
@@ -62,28 +59,10 @@ export function LoginMobilePage() {
return return
} }
setLoading(true) resetFlow("forgotPassword")
try { clearOtpDelivery("login")
await sendOtp(state.login.mobile, "login") markOtpSendPending("login")
clearCooldown("loginOtpSend") navigate("/auth/login/verify")
resetFlow("forgotPassword")
navigate("/auth/login/verify")
toast.success(t.login.toasts.verifySent)
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "loginOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setLoading(false)
}
} }
return ( return (
@@ -99,7 +78,6 @@ export function LoginMobilePage() {
type="tel" type="tel"
dir="ltr" dir="ltr"
maxLength={11} maxLength={11}
disabled={loading}
value={state.login.mobile} value={state.login.mobile}
onChange={(event) => setMobile("login", event.target.value)} onChange={(event) => setMobile("login", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`} className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
@@ -107,10 +85,9 @@ export function LoginMobilePage() {
<Button <Button
onClick={handleLogin} onClick={handleLogin}
disabled={loading || state.cooldowns.loginOtpSend > 0} disabled={state.cooldowns.loginOtpSend > 0}
className="h-11 w-full" className="h-11 w-full"
> >
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldownLabel || t.login.loginCta} {cooldownLabel || t.login.loginCta}
</Button> </Button>
@@ -129,7 +106,6 @@ export function LoginMobilePage() {
type="button" type="button"
variant="outline" variant="outline"
onClick={startGoogleLogin} onClick={startGoogleLogin}
disabled={loading}
className="h-11 w-full" className="h-11 w-full"
> >
<GoogleIcon /> <GoogleIcon />

View File

@@ -1,56 +1,148 @@
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { useMemo, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { Link, Navigate, useNavigate } from "react-router-dom" import { Link, Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { loginWithOtp } from "../../api/users" import { loginWithOtp, sendOtp } from "../../api/users"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext" import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthOtpInput } from "./AuthOtpInput"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { completeAuthentication, formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" import {
completeAuthentication,
formatCooldown,
getApiErrorMessage,
getOtpRemainingSeconds,
handleThrottleError,
} from "./utils"
export function LoginOtpPage() { export function LoginOtpPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang } = useTranslation() const { t, lang } = useTranslation()
const { state, setCode, setCooldown, clearCooldown } = useAuthFlow() const {
state,
setCode,
setCooldown,
clearCooldown,
setOtpDelivery,
clearOtpDelivery,
} = useAuthFlow()
const isRtl = lang === "fa" const isRtl = lang === "fa"
const [loading, setLoading] = useState(false) const autoSendStartedRef = useRef(false)
const activeSubmitCodeRef = useRef("")
const lastFailedCodeRef = useRef("")
const [isSendingOtp, setIsSendingOtp] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [now, setNow] = useState(Date.now())
if (!state.login.mobile) { if (!state.login.mobile) {
return <Navigate to="/auth/login" replace /> return <Navigate to="/auth/login" replace />
} }
const alert = useMemo(() => { useEffect(() => {
if (state.cooldowns.loginOtpVerify <= 0) { if (!state.login.otpExpiresAt || getOtpRemainingSeconds(state.login.otpExpiresAt) <= 0) {
return null return
} }
const formatted = formatCooldown(state.cooldowns.loginOtpVerify, isRtl) const timer = window.setInterval(() => {
return { setNow(Date.now())
title: t.login.throttle.title, }, 1000)
description: t.login.throttle.otpLoginMessage(formatted),
return () => window.clearInterval(timer)
}, [state.login.otpExpiresAt])
const sendLoginOtp = async () => {
setIsSendingOtp(true)
try {
const response = await sendOtp(state.login.mobile, "login")
clearCooldown("loginOtpSend")
clearCooldown("loginOtpVerify")
lastFailedCodeRef.current = ""
setCode("login", "")
setOtpDelivery("login", response.expires_in_seconds)
setNow(Date.now())
toast.success(t.login.toasts.verifySent)
} catch (error) {
clearOtpDelivery("login")
if (
!handleThrottleError({
error,
cooldownKey: "loginOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setIsSendingOtp(false)
} }
}, [isRtl, state.cooldowns.loginOtpVerify, t.login.throttle]) }
const cooldownLabel = useEffect(() => {
state.cooldowns.loginOtpVerify > 0 if (!state.login.pendingOtpSend || autoSendStartedRef.current) {
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginOtpVerify, isRtl)) return
: null }
const handleVerify = async (event: React.FormEvent) => { autoSendStartedRef.current = true
event.preventDefault() void sendLoginOtp()
}, [state.login.pendingOtpSend])
if (!state.login.code) { const otpRemainingSeconds = state.login.otpExpiresAt
? Math.max(0, Math.ceil((state.login.otpExpiresAt - now) / 1000))
: 0
const throttleAlert = useMemo(() => {
if (state.cooldowns.loginOtpVerify > 0) {
const formatted = formatCooldown(state.cooldowns.loginOtpVerify, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpLoginMessage(formatted),
}
}
if (state.cooldowns.loginOtpSend > 0) {
const formatted = formatCooldown(state.cooldowns.loginOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}
return null
}, [isRtl, state.cooldowns.loginOtpSend, state.cooldowns.loginOtpVerify, t.login.throttle])
const expiryMessage =
otpRemainingSeconds > 0
? t.login.otpExpiresIn(formatCooldown(otpRemainingSeconds, isRtl))
: state.login.otpExpiresAt
? t.login.otpExpired
: null
const submitCode = async (code: string) => {
if (code.length !== 5) {
toast.error(t.login.toasts.enterOtp) toast.error(t.login.toasts.enterOtp)
return return
} }
setLoading(true) if (isSendingOtp || isSubmitting || code === activeSubmitCodeRef.current) {
return
}
if (code === lastFailedCodeRef.current) {
toast.error(t.login.toasts.invalidOtp)
return
}
activeSubmitCodeRef.current = code
setIsSubmitting(true)
try { try {
const data = await loginWithOtp(state.login.mobile, state.login.code) const data = await loginWithOtp(state.login.mobile, code)
clearCooldown("loginOtpVerify") clearCooldown("loginOtpVerify")
activeSubmitCodeRef.current = ""
lastFailedCodeRef.current = ""
completeAuthentication({ completeAuthentication({
access: data.access, access: data.access,
refresh: data.refresh, refresh: data.refresh,
@@ -68,35 +160,67 @@ export function LoginOtpPage() {
throttleCopy: t.login.throttle, throttleCopy: t.login.throttle,
}) })
) { ) {
lastFailedCodeRef.current = code
toast.error(getApiErrorMessage(error, t.login.toasts.invalidOtp)) toast.error(getApiErrorMessage(error, t.login.toasts.invalidOtp))
} }
} finally { } finally {
setLoading(false) activeSubmitCodeRef.current = ""
setIsSubmitting(false)
} }
} }
const handleResend = async () => {
autoSendStartedRef.current = false
await sendLoginOtp()
}
const isBusy = isSendingOtp || isSubmitting
const isRepeatedInvalidCode = state.login.code.length === 5 && state.login.code === lastFailedCodeRef.current
return ( return (
<AuthPanel <AuthPanel
title={t.login.loginOtpTitle} title={t.login.loginOtpTitle}
description={t.login.sentCodeDesc(state.login.mobile)} description={t.login.sentCodeDesc(state.login.mobile)}
alert={alert} alert={throttleAlert}
> >
<form onSubmit={handleVerify} className="grid gap-4"> <form
<Input onSubmit={(event) => {
event.preventDefault()
void submitCode(state.login.code)
}}
className="grid gap-4"
>
<AuthOtpInput
id="login-otp" id="login-otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
maxLength={5}
disabled={loading}
value={state.login.code} value={state.login.code}
onChange={(event) => setCode("login", event.target.value)} disabled={isBusy}
className="h-11 text-center text-lg tracking-widest" onChange={(value) => setCode("login", value)}
onComplete={(value) => void submitCode(value)}
/> />
<Button type="submit" className="h-11 w-full" disabled={loading || state.cooldowns.loginOtpVerify > 0}> {expiryMessage ? (
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />} <p className="text-center text-sm text-slate-500 dark:text-slate-400">{expiryMessage}</p>
{cooldownLabel || t.login.verifyAndContinue} ) : null}
<Button
type="submit"
className="h-11 w-full"
disabled={isBusy || isRepeatedInvalidCode || state.cooldowns.loginOtpVerify > 0}
>
{(isSubmitting || isSendingOtp) && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{isSubmitting ? t.login.verifyingOtp : isSendingOtp ? t.login.sendingOtp : t.login.verifyAndContinue}
</Button>
<Button
type="button"
variant="outline"
className="h-11 w-full"
disabled={isBusy || otpRemainingSeconds > 0 || state.cooldowns.loginOtpSend > 0}
onClick={() => void handleResend()}
>
{state.cooldowns.loginOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginOtpSend, isRtl))
: t.login.resendOtp}
</Button> </Button>
<div className="text-center text-sm text-slate-500 dark:text-slate-400 underline"> <div className="text-center text-sm text-slate-500 dark:text-slate-400 underline">

View File

@@ -1,22 +1,19 @@
import { Loader2 } from "lucide-react" import { useMemo } from "react"
import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom" import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { sendOtp } from "../../api/users"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext" import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" import { formatCooldown } from "./utils"
export function SignupMobilePage() { export function SignupMobilePage() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang } = useTranslation() const { t, lang } = useTranslation()
const { state, setMobile, setCode, setCooldown, clearCooldown } = useAuthFlow() const { state, setMobile, markOtpSendPending, clearOtpDelivery } = useAuthFlow()
const isRtl = lang === "fa" const isRtl = lang === "fa"
const [loading, setLoading] = useState(false)
const alert = useMemo(() => { const alert = useMemo(() => {
if (state.cooldowns.signupOtpSend <= 0) { if (state.cooldowns.signupOtpSend <= 0) {
@@ -41,28 +38,9 @@ export function SignupMobilePage() {
return return
} }
setLoading(true) clearOtpDelivery("signup")
try { markOtpSendPending("signup")
await sendOtp(state.signup.mobile, "register") navigate("/auth/signup/verify")
clearCooldown("signupOtpSend")
setCode("signup", "")
navigate("/auth/signup/verify")
toast.success(t.login.toasts.verifySent)
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "signupOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setLoading(false)
}
} }
return ( return (
@@ -78,7 +56,6 @@ export function SignupMobilePage() {
type="tel" type="tel"
dir="ltr" dir="ltr"
maxLength={11} maxLength={11}
disabled={loading}
value={state.signup.mobile} value={state.signup.mobile}
onChange={(event) => setMobile("signup", event.target.value)} onChange={(event) => setMobile("signup", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`} className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
@@ -86,10 +63,9 @@ export function SignupMobilePage() {
<Button <Button
onClick={handleContinue} onClick={handleContinue}
disabled={loading || state.cooldowns.signupOtpSend > 0} disabled={state.cooldowns.signupOtpSend > 0}
className="h-11 w-full" className="h-11 w-full"
> >
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldownLabel || t.login.sendSignupCode} {cooldownLabel || t.login.sendSignupCode}
</Button> </Button>

View File

@@ -1,55 +1,161 @@
import { useState } from "react" import { Loader2 } from "lucide-react"
import { Link, Navigate, useNavigate } from "react-router-dom" import { useEffect, useMemo, useRef, useState } from "react"
import { Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { sendOtp } from "../../api/users"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext" import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthOtpInput } from "./AuthOtpInput"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { formatCooldown, getApiErrorMessage, getOtpRemainingSeconds, handleThrottleError } from "./utils"
export function SignupOtpPage() { export function SignupOtpPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation() const { t, lang } = useTranslation()
const { state, setCode } = useAuthFlow() const {
const [loading, setLoading] = useState(false) state,
setCode,
setCooldown,
clearCooldown,
setOtpDelivery,
clearOtpDelivery,
} = useAuthFlow()
const isRtl = lang === "fa"
const autoSendStartedRef = useRef(false)
const [isSendingOtp, setIsSendingOtp] = useState(false)
const [isContinuing, setIsContinuing] = useState(false)
const [now, setNow] = useState(Date.now())
if (!state.signup.mobile) { if (!state.signup.mobile) {
return <Navigate to="/auth/signup" replace /> return <Navigate to="/auth/signup" replace />
} }
const handleContinue = async (event: React.FormEvent) => { useEffect(() => {
event.preventDefault() if (!state.signup.otpExpiresAt || getOtpRemainingSeconds(state.signup.otpExpiresAt) <= 0) {
return
}
if (!state.signup.code) { const timer = window.setInterval(() => {
setNow(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [state.signup.otpExpiresAt])
const sendSignupOtp = async () => {
setIsSendingOtp(true)
try {
const response = await sendOtp(state.signup.mobile, "register")
clearCooldown("signupOtpSend")
setCode("signup", "")
setOtpDelivery("signup", response.expires_in_seconds)
setNow(Date.now())
toast.success(t.login.toasts.verifySent)
} catch (error) {
clearOtpDelivery("signup")
if (
!handleThrottleError({
error,
cooldownKey: "signupOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setIsSendingOtp(false)
}
}
useEffect(() => {
if (!state.signup.pendingOtpSend || autoSendStartedRef.current) {
return
}
autoSendStartedRef.current = true
void sendSignupOtp()
}, [state.signup.pendingOtpSend])
const otpRemainingSeconds = state.signup.otpExpiresAt
? Math.max(0, Math.ceil((state.signup.otpExpiresAt - now) / 1000))
: 0
const alert = useMemo(() => {
if (state.cooldowns.signupOtpSend <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.signupOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}, [isRtl, state.cooldowns.signupOtpSend, t.login.throttle])
const expiryMessage =
otpRemainingSeconds > 0
? t.login.otpExpiresIn(formatCooldown(otpRemainingSeconds, isRtl))
: state.signup.otpExpiresAt
? t.login.otpExpired
: null
const continueToPassword = async (code: string) => {
if (code.length !== 5) {
toast.error(t.login.toasts.enterOtp) toast.error(t.login.toasts.enterOtp)
return return
} }
setLoading(true) setIsContinuing(true)
setCode("signup", code)
navigate("/auth/signup/password") navigate("/auth/signup/password")
} }
const isBusy = isSendingOtp || isContinuing
return ( return (
<AuthPanel <AuthPanel
title={t.login.signupVerifyTitle} title={t.login.signupVerifyTitle}
description={t.login.sentCodeDesc(state.signup.mobile)} description={t.login.sentCodeDesc(state.signup.mobile)}
alert={alert}
> >
<form onSubmit={handleContinue} className="grid gap-4"> <form
<Input onSubmit={(event) => {
event.preventDefault()
void continueToPassword(state.signup.code)
}}
className="grid gap-4"
>
<AuthOtpInput
id="signup-otp" id="signup-otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
maxLength={6}
disabled={loading}
value={state.signup.code} value={state.signup.code}
onChange={(event) => setCode("signup", event.target.value)} disabled={isBusy}
className="h-11 text-center text-lg tracking-widest" onChange={(value) => setCode("signup", value)}
onComplete={(value) => void continueToPassword(value)}
/> />
<Button type="submit" className="h-11 w-full" disabled={loading}> {expiryMessage ? (
{t.login.continueToPassword} <p className="text-center text-sm text-slate-500 dark:text-slate-400">{expiryMessage}</p>
) : null}
<Button type="submit" className="h-11 w-full" disabled={isBusy}>
{(isSendingOtp || isContinuing) && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{isSendingOtp ? t.login.sendingOtp : t.login.continueToPassword}
</Button>
<Button
type="button"
variant="outline"
className="h-11 w-full"
disabled={isBusy || otpRemainingSeconds > 0 || state.cooldowns.signupOtpSend > 0}
onClick={() => void sendSignupOtp()}
>
{state.cooldowns.signupOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.signupOtpSend, isRtl))
: t.login.resendOtp}
</Button> </Button>
</form> </form>
</AuthPanel> </AuthPanel>

View File

@@ -9,7 +9,7 @@ import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { AuthPasswordField } from "./AuthPasswordField" import { AuthPasswordField } from "./AuthPasswordField"
import { completeAuthentication, getApiErrorMessage } from "./utils" import { completeAuthentication, getApiErrorMessage, getPasswordValidationMessage } from "./utils"
export function SignupPasswordPage() { export function SignupPasswordPage() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -40,6 +40,12 @@ export function SignupPasswordPage() {
return return
} }
const passwordValidationMessage = getPasswordValidationMessage(password, t.login)
if (passwordValidationMessage) {
toast.error(passwordValidationMessage)
return
}
setLoading(true) setLoading(true)
try { try {
const data = await registerWithOtp(state.signup.mobile, state.signup.code, password, confirmation) const data = await registerWithOtp(state.signup.mobile, state.signup.code, password, confirmation)

View File

@@ -3,7 +3,7 @@ import { toast } from "sonner"
import { ApiError } from "../../api/client" import { ApiError } from "../../api/client"
import { setSessionTokens } from "../../lib/session" import { setSessionTokens } from "../../lib/session"
const PERSIAN_DIGITS = ["\u06f0", "\u06f1", "\u06f2", "\u06f3", "\u06f4", "\u06f5", "\u06f6", "\u06f7", "\u06f8", "\u06f9"] const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]
export const localizeDigits = (value: string, isRtl: boolean) => export const localizeDigits = (value: string, isRtl: boolean) =>
isRtl ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit) : value isRtl ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit) : value
@@ -17,6 +17,14 @@ export const formatCooldown = (seconds: number, isRtl: boolean) => {
return localizeDigits(base, isRtl) return localizeDigits(base, isRtl)
} }
export const getOtpRemainingSeconds = (expiresAt: number | null) => {
if (!expiresAt) {
return 0
}
return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
}
export const getApiErrorMessage = (error: unknown, fallbackMessage: string) => { export const getApiErrorMessage = (error: unknown, fallbackMessage: string) => {
if (error instanceof ApiError) { if (error instanceof ApiError) {
return error.message return error.message
@@ -29,6 +37,24 @@ export const getApiErrorMessage = (error: unknown, fallbackMessage: string) => {
return fallbackMessage return fallbackMessage
} }
export const getPasswordValidationMessage = (
password: string,
copy: {
passwordRequirements: string
},
) => {
const hasLowercase = /[a-z]/.test(password)
const hasUppercase = /[A-Z]/.test(password)
const hasDigit = /\d/.test(password)
const hasSymbol = /[^A-Za-z0-9]/.test(password)
if (password.length < 8 || !hasLowercase || !hasUppercase || !hasDigit || !hasSymbol) {
return copy.passwordRequirements
}
return null
}
export const handleThrottleError = ({ export const handleThrottleError = ({
error, error,
cooldownKey, cooldownKey,

View File

@@ -9,6 +9,7 @@ export interface Client {
id: string; id: string;
name: string; name: string;
notes: string | null; notes: string | null;
thumbnail?: string | null;
workspace: string; workspace: string;
created_by?: AuditUser | null; created_by?: AuditUser | null;
can_delete: boolean; can_delete: boolean;

View File

@@ -11,6 +11,7 @@
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,