Add i18n foundation (fa-IR/en-US) and localize landing + auth.

Introduce path-prefixed locale routing under app/[lang] with a middleware
that detects locale from cookie/Accept-Language (default fa-IR) and
redirects. Add fa-IR (source of truth) and en-US dictionaries, a server
getDictionary, a client I18nProvider/useT, locale-aware Link + router
helpers, and a language switcher. The root [lang] layout sets html
lang/dir and the per-locale font (Peyda for fa, Inter for en).

Landing sections and the login/register/auth shell now read all copy from
the dictionaries; dashboard localization follows in a later commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-11 12:05:03 +03:30
parent 2b16846f67
commit 34993d417f
48 changed files with 785 additions and 200 deletions
@@ -0,0 +1,397 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import type { AdminUser } from '@/types';
import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
export default function AdminUsersPage() {
const queryClient = useQueryClient();
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.role === 'admin';
const canStaffResetPassword =
currentUser?.role === 'admin' || currentUser?.role === 'technical';
const canResetPasswordFor = (user: AdminUser) => {
if (!canStaffResetPassword) return false;
if (currentUser?.role === 'admin') return true;
return user.role === 'user';
};
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [pwdModalUser, setPwdModalUser] = useState<AdminUser | null>(null);
const [pwdModalPassword, setPwdModalPassword] = useState('');
const [form, setForm] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
role: 'user' as 'user' | 'admin' | 'technical' | 'sales',
});
const { data: users = [], isLoading } = useQuery<AdminUser[]>({
queryKey: ['admin-users', search],
queryFn: () =>
api.get('/users', { params: search ? { search } : {} }).then((r) => r.data),
});
const createUser = useMutation({
mutationFn: (data: typeof form) => api.post('/users', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('User created successfully');
setShowForm(false);
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'user' });
},
onError: (err: any) => {
toast.error(err?.response?.data?.message || 'Failed to create user');
},
});
const toggleActive = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
api.patch(`/users/${id}/${isActive ? 'deactivate' : 'activate'}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('User updated');
},
});
const changeRole = useMutation({
mutationFn: ({ id, role }: { id: string; role: string }) =>
api.patch(`/users/${id}/role`, { role }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('Role updated');
},
});
const resetPassword = useMutation({
mutationFn: ({ id, password }: { id: string; password: string }) =>
api.patch(`/users/${id}/password`, { password }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('Password updated');
setPwdModalUser(null);
setPwdModalPassword('');
},
onError: (err: unknown) => {
const msg =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
toast.error(typeof msg === 'string' ? msg : 'Failed to update password');
},
});
const openPwdModal = (user: AdminUser) => {
setPwdModalUser(user);
setPwdModalPassword('');
};
const closePwdModal = () => {
if (resetPassword.isPending) return;
setPwdModalUser(null);
setPwdModalPassword('');
};
const submitPwdModal = () => {
if (!pwdModalUser || pwdModalPassword.length < 8) return;
resetPassword.mutate({ id: pwdModalUser.id, password: pwdModalPassword });
};
return (
<div className="space-y-6 animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">User Management</h1>
<p className="page-subtitle">{users.length} user{users.length !== 1 ? 's' : ''} registered</p>
</div>
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add User'}
</button>
</div>
{/* Create user form */}
{showForm && (
<div className="card space-y-4 animate-slide-up">
<h2 className="text-lg font-semibold text-gray-900">Create New User</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">First Name</label>
<input
className="input-field"
placeholder="John"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
<input
className="input-field"
placeholder="Doe"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
className="input-field"
type="email"
placeholder="john@example.com"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
className="input-field"
type="password"
placeholder="Min 8 characters"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select
className="input-field w-full sm:w-48"
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select>
</div>
<button
onClick={() => createUser.mutate(form)}
disabled={!form.email || !form.password || !form.firstName || !form.lastName || createUser.isPending}
className="btn-primary disabled:opacity-50"
>
{createUser.isPending ? <><Clock className="w-4 h-4 inline animate-spin" /> Creating...</> : 'Create User'}
</button>
</div>
)}
{/* Search bar */}
<div className="relative">
<input
className="input-field pl-10 w-full"
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
</div>
{isLoading ? (
<div className="space-y-3">
{[1,2,3].map(i => (
<div key={i} className="card flex items-center gap-4">
<div className="skeleton w-10 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<div className="skeleton h-4 w-32" />
<div className="skeleton h-3 w-48" />
</div>
<div className="skeleton h-6 w-16 rounded-full" />
</div>
))}
</div>
) : users.length === 0 ? (
<div className="card text-center py-16">
<Users className="w-12 h-12 mx-auto text-gray-300 mb-4" />
<p className="text-gray-600 font-medium">{search ? 'No users found matching your search.' : 'No users yet.'}</p>
</div>
) : (
<>
{/* Desktop Table */}
<div className="hidden lg:block table-wrapper">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50/80">
<tr>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">User</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Role</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Apps</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Created</th>
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</td>
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
<td className="px-6 py-4">
{isAdmin ? (
<select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select>
) : (
<span className={`badge ${
user.role === 'admin' ? 'badge-purple' :
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
'badge-gray'
}`}>{user.role}</span>
)}
</td>
<td className="px-6 py-4">
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4">
<span className="badge badge-blue">{user.appCount ?? 0}</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
{canResetPasswordFor(user) && (
<button
type="button"
onClick={() => openPwdModal(user)}
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-800"
>
<KeyRound className="w-3.5 h-3.5" /> Password
</button>
)}
<button
type="button"
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm font-medium ${user.isActive ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'}`}
>
{user.isActive ? 'Deactivate' : 'Activate'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="lg:hidden grid gap-3">
{users.map((user) => (
<div key={user.id} className="card space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">{user.firstName} {user.lastName}</h3>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="badge badge-blue">{user.appCount ?? 0} apps</span>
<span className={`badge ${
user.role === 'admin' ? 'badge-purple' :
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
'badge-gray'
}`}>{user.role}</span>
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
{isAdmin ? (
<select
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={user.role}
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="technical">Technical</option>
<option value="sales">Sales</option>
</select>
) : (
<span className="text-sm text-gray-500 capitalize">{user.role}</span>
)}
{canResetPasswordFor(user) && (
<button
type="button"
onClick={() => openPwdModal(user)}
className="btn-secondary text-xs inline-flex items-center gap-1"
>
<KeyRound className="w-3.5 h-3.5" /> Password
</button>
)}
<button
type="button"
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
>
{user.isActive ? 'Deactivate' : 'Activate'}
</button>
</div>
</div>
))}
</div>
</>
)}
{pwdModalUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="presentation"
onClick={(e) => e.target === e.currentTarget && closePwdModal()}
>
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Set password</h3>
<p className="text-sm text-gray-600">
New password for{' '}
<strong>
{pwdModalUser.firstName} {pwdModalUser.lastName}
</strong>{' '}
({pwdModalUser.email})
</p>
<input
type="password"
className="input-field w-full"
placeholder="Min 8 characters"
value={pwdModalPassword}
onChange={(e) => setPwdModalPassword(e.target.value)}
autoComplete="new-password"
/>
<div className="flex justify-end gap-2 pt-2">
<button type="button" className="btn-ghost" onClick={closePwdModal}>
Cancel
</button>
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={pwdModalPassword.length < 8 || resetPassword.isPending}
onClick={submitPwdModal}
>
{resetPassword.isPending ? 'Saving...' : 'Save password'}
</button>
</div>
</div>
</div>
)}
</div>
);
}