refactor: Thay thế hook dịch thuật tùy chỉnh bằng hook useTranslations từ next-intl, cập nhật các thành phần liên quan đến dịch thuật và điều hướng sau khi đăng nhập thành công.

This commit is contained in:
Ho Ngoc Hai
2026-01-04 21:49:56 +07:00
parent df5545e7b5
commit 2d783af67f
38 changed files with 4114 additions and 144 deletions

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import LoginPage from '../app/(auth)/login/page';
import { useAuthStore } from '../stores/auth-store';
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}));
// Mock useTranslation
vi.mock('../shared/hooks/use-translation', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock auth store
vi.mock('../stores/auth-store', () => ({
useAuthStore: vi.fn(),
}));
describe('Auth Flow Integration', () => {
let queryClient: QueryClient;
let mockAuthStore: any;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
mockAuthStore = {
login: vi.fn(),
isLoading: false,
user: null,
isAuthenticated: false,
};
(useAuthStore as any).mockReturnValue(mockAuthStore);
});
const renderLoginPage = () => {
return render(
<QueryClientProvider client={queryClient}>
<LoginPage />
</QueryClientProvider>
);
};
describe('Login Page', () => {
it('renders login form with required fields', () => {
renderLoginPage();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
expect(screen.getByText(/remember me/i)).toBeInTheDocument();
});
it('shows validation errors for empty fields', async () => {
renderLoginPage();
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/validation\.emailRequired/i)).toBeInTheDocument();
expect(screen.getByText(/validation\.password/i)).toBeInTheDocument();
});
});
it('shows validation error for invalid email', async () => {
renderLoginPage();
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/validation\.email/i)).toBeInTheDocument();
});
});
it('calls login function on valid form submission', async () => {
mockAuthStore.login.mockResolvedValue(undefined);
renderLoginPage();
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthStore.login).toHaveBeenCalledWith('test@example.com', 'password123');
});
});
it('shows loading state during login', async () => {
mockAuthStore.isLoading = true;
mockAuthStore.login.mockImplementation(() => new Promise(() => {})); // Never resolves
renderLoginPage();
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
expect(screen.getByText(/auth\.login\.loginButtonLoading/i)).toBeInTheDocument();
expect(submitButton).toBeDisabled();
});
it('shows error message on login failure', async () => {
const errorMessage = 'Invalid credentials';
mockAuthStore.login.mockRejectedValue(new Error(errorMessage));
renderLoginPage();
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UsersTable } from '../features/shared/components/users/UsersTable';
import { UserCard } from '../features/shared/components/users/UserCard';
import { UserForm } from '../features/shared/components/users/UserForm';
/**
* EN: Smoke tests for users components
* VI: Smoke tests cho users components
*
* These tests ensure components render without crashing and have basic functionality.
*/
describe('Users Components - Smoke Tests', () => {
const mockUser = {
id: '1',
email: 'test@example.com',
role: 'USER',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
const mockUsers = [mockUser];
describe('UsersTable', () => {
it('renders without crashing', () => {
render(<UsersTable users={mockUsers} />);
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('renders loading state', () => {
render(<UsersTable users={[]} loading={true} />);
expect(screen.getByText('Loading users...')).toBeInTheDocument();
});
it('renders empty state', () => {
render(<UsersTable users={[]} />);
expect(screen.getByText('No users found')).toBeInTheDocument();
});
it('renders bulk actions when users selected', () => {
// This would require more complex setup with user interactions
// For smoke test, just ensure it renders
render(<UsersTable users={mockUsers} showBulkActions />);
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
});
describe('UserCard', () => {
it('renders user information', () => {
render(<UserCard user={mockUser} />);
expect(screen.getByText('test@example.com')).toBeInTheDocument();
expect(screen.getByText('USER')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('renders compact mode', () => {
render(<UserCard user={mockUser} compact />);
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('shows admin actions when enabled', () => {
render(<UserCard user={mockUser} showAdminActions />);
expect(screen.getByText('Edit')).toBeInTheDocument();
});
});
describe('UserForm', () => {
it('renders create form', () => {
render(
<UserForm
isCreate
onSubmit={() => {}}
onCancel={() => {}}
/>
);
expect(screen.getByText('Create New User')).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it('renders edit form', () => {
render(
<UserForm
user={mockUser}
onSubmit={() => {}}
onCancel={() => {}}
/>
);
expect(screen.getByText('Edit User')).toBeInTheDocument();
expect(screen.getByDisplayValue('test@example.com')).toBeInTheDocument();
});
it('shows validation errors', async () => {
render(
<UserForm
isCreate
onSubmit={() => {}}
onCancel={() => {}}
/>
);
const submitButton = screen.getByRole('button', { name: /create user/i });
submitButton.click();
// Note: Form validation requires react-hook-form setup
// This is just a basic smoke test
expect(submitButton).toBeInTheDocument();
});
});
});

View File

@@ -9,7 +9,7 @@ import { authApi } from '@/services/api/auth.api';
import { Button } from '@/features/shared/components/ui/button';
import { Input } from '@/features/shared/components/ui/input';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/features/shared/components/ui/card';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: Create forgot password schema with translated messages
@@ -45,7 +45,7 @@ function createForgotPasswordSchema(t: (key: string) => string) {
*/
export default function ForgotPasswordPage() {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
// EN: Success state - shows confirmation after email is sent
// VI: Trạng thái thành công - hiển thị xác nhận sau khi email được gửi

View File

@@ -10,7 +10,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { Button } from '@/features/shared/components/ui/button';
import { Input } from '@/features/shared/components/ui/input';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/features/shared/components/ui/card';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: Create login schema with translated messages
@@ -49,7 +49,7 @@ function createLoginSchema(t: (key: string) => string) {
*/
export default function LoginPage() {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
// EN: Next.js router for navigation
// VI: Next.js router để điều hướng
@@ -96,9 +96,9 @@ export default function LoginPage() {
// EN: Attempt login through auth store
// VI: Thử đăng nhập thông qua auth store
await login(data.email, data.password);
// EN: Redirect to home page on successful login
// VI: Chuyển hướng về trang chủ khi đăng nhập thành công
router.push('/');
// EN: Redirect to dashboard on successful login
// VI: Chuyển hướng về dashboard khi đăng nhập thành công
router.push('/dashboard');
} catch (err: any) {
// EN: Set error message from API response
// VI: Đặt thông báo lỗi từ phản hồi API

View File

@@ -10,7 +10,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { Button } from '@/features/shared/components/ui/button';
import { Input } from '@/features/shared/components/ui/input';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/features/shared/components/ui/card';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: Create register schema with translated messages
@@ -129,7 +129,7 @@ function calculatePasswordStrength(password: string): {
*/
export default function RegisterPage() {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
// EN: Next.js router for navigation
// VI: Next.js router để điều hướng
@@ -195,7 +195,7 @@ export default function RegisterPage() {
await registerUser(data.email, data.password, data.confirmPassword);
// EN: Redirect to home page on successful registration
// VI: Chuyển hướng về trang chủ khi đăng ký thành công
router.push('/');
router.push('/dashboard');
} catch (err: any) {
// EN: Set error message from API response
// VI: Đặt thông báo lỗi từ phản hồi API

View File

@@ -41,7 +41,7 @@ import {
BarChart3,
Zap,
} from 'lucide-react';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
import { cn } from '@/shared/lib/utils';
/**
@@ -49,7 +49,7 @@ import { cn } from '@/shared/lib/utils';
* VI: Card Thống kê Tài khoản
*/
function AccountStatsCard() {
const { t } = useTranslation();
const t = useTranslations();
const stats = [
{
@@ -134,7 +134,7 @@ function AccountStatsCard() {
* VI: Card Hành động Nhanh
*/
function QuickActionsCard() {
const { t } = useTranslation();
const t = useTranslations();
const actions = [
{
@@ -206,7 +206,7 @@ function QuickActionsCard() {
* VI: Card Thông tin Tài khoản
*/
function AccountInfoCard() {
const { t } = useTranslation();
const t = useTranslations();
// EN: Mock user data / VI: Dữ liệu user mẫu
const userData = {
@@ -336,7 +336,7 @@ function AccountInfoCard() {
* VI: Card Hoạt động Gần đây
*/
function RecentActivityCard() {
const { t } = useTranslation();
const t = useTranslations();
// EN: Mock activity data / VI: Dữ liệu hoạt động mẫu
const activities = [
@@ -431,7 +431,7 @@ function RecentActivityCard() {
* VI: Card Trạng thái Bảo mật Tài khoản
*/
function SecurityStatusCard() {
const { t } = useTranslation();
const t = useTranslations();
const securityItems = [
{
@@ -554,7 +554,7 @@ function SecurityStatusCard() {
* VI: Trang Mockup Tài khoản Chính
*/
export default function AccountMockupPage() {
const { t } = useTranslation();
const t = useTranslations();
return (
<div className="min-h-screen bg-bg-primary">

View File

@@ -10,7 +10,7 @@ const TypingIndicator = React.lazy(() => import('@/features/chat/typing-indicato
import { LiveRegion } from '@/features/shared/components/accessibility/live-region';
import { useKeyboardShortcuts, CHAT_SHORTCUTS } from '@/shared/hooks/use-keyboard-shortcuts';
import { useChatStore, MessageSender } from '@/stores/chat-store';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: Chat page component - Main chat interface
@@ -34,7 +34,7 @@ import { useTranslation } from '@/shared/hooks/use-translation';
*/
export default function ChatPage() {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
const [sidebarVisible, setSidebarVisible] = React.useState(true);
const [announcement, setAnnouncement] = React.useState<string>('');

View File

@@ -32,7 +32,8 @@ import {
AlertCircle,
CheckCircle2,
} from 'lucide-react';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
import { useI18n } from '@/features/theme';
/**
* EN: API Key interface
@@ -118,7 +119,8 @@ function formatDate(date: string | null, t: (key: string) => string, locale: str
*/
export default function ApiKeysPage() {
// EN: Translation hook / VI: Hook translation
const { t, locale } = useTranslation();
const t = useTranslations();
const { locale } = useI18n();
// EN: Create schema with translations / VI: Tạo schema với translations
const apiKeySchema = createApiKeySchema(t);

View File

@@ -11,7 +11,7 @@ import {
CreditCard,
Key,
} from 'lucide-react';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: Settings navigation tabs configuration
@@ -74,7 +74,7 @@ export default function SettingsLayout({
children: React.ReactNode;
}) {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
const pathname = usePathname();
const settingsTabs = getSettingsTabs(t);

View File

@@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/fea
import { Select } from '@/features/shared/components/ui/select';
import { Switch } from '@/features/shared/components/ui/switch';
import { Button } from '@/features/shared/components/ui/button';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
import { useI18n } from '@/features/theme';
import { type Locale } from '@/features/theme/i18n-config';
@@ -70,7 +70,7 @@ const defaultPreferences: Preferences = {
*/
export default function PreferencesPage() {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
const { locale, setLocale } = useI18n();
const { theme, setTheme } = useTheme();
const [preferences, setPreferences] = React.useState<Preferences>(defaultPreferences);

View File

@@ -6,7 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useAuthStore } from '@/stores/auth-store';
import { userApi, type UserProfile, type UpdateUserProfileDto } from '@/services/api/user.api';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: Create profile schema with translated messages
@@ -50,7 +50,7 @@ function createProfileSchema(
*/
export default function ProfilePage() {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
const { user } = useAuthStore();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);

View File

@@ -0,0 +1,273 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, User, Mail, Calendar, Shield, Edit } from 'lucide-react';
import { useUsersStore } from '../../../../stores/users-store';
import { UserCard, UserForm } from '../../../../features/shared/components/users';
import { Button } from '../../../../features/shared/components/ui/button';
import { Card } from '../../../../features/shared/components/ui/card';
import { AuthGuard } from '../../../../features/shared/middleware/auth-guard';
import { useAuthStore } from '../../../../stores/auth-store';
/**
* EN: Admin User Detail/Edit Page
* VI: Trang chi tiết/edit User cho Admin
*
* Features:
* - Display user details
* - Edit user information
* - Role management
* - Activity logs (placeholder)
* - Breadcrumb navigation
*/
export default function AdminUserDetailPage() {
const params = useParams();
const router = useRouter();
const userId = params.id as string;
const { user: currentUser } = useAuthStore();
const {
currentUser: user,
isLoadingUser,
error,
fetchUser,
updateUser,
clearCurrentUser,
clearError,
} = useUsersStore();
const [isEditing, setIsEditing] = useState(false);
// Check permissions
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const canEdit = currentUser?.role === 'SUPER_ADMIN' ||
(currentUser?.role === 'ADMIN' && user?.role !== 'SUPER_ADMIN');
// Load user data
useEffect(() => {
if (isAdmin && userId) {
fetchUser(userId);
}
}, [isAdmin, userId, fetchUser]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearCurrentUser();
};
}, [clearCurrentUser]);
const handleEditSubmit = async (userData: any) => {
if (!user) return;
try {
await updateUser(user.id, userData);
setIsEditing(false);
clearError();
} catch (error) {
// Error handled in store
}
};
if (isLoadingUser) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="p-8 text-center max-w-md">
<h2 className="text-2xl font-bold text-red-600 mb-4">Error</h2>
<p className="text-gray-600 mb-6">{error}</p>
<div className="flex gap-4 justify-center">
<Button onClick={() => fetchUser(userId)}>
Try Again
</Button>
<Button variant="outline" onClick={() => router.back()}>
Go Back
</Button>
</div>
</Card>
</div>
);
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">User Not Found</h2>
<p className="text-gray-600 mb-6">
The user you're looking for doesn't exist.
</p>
<Button onClick={() => router.push('/admin/users')}>
Back to Users
</Button>
</Card>
</div>
);
}
return (
<AuthGuard requiredRoles={['ADMIN', 'SUPER_ADMIN']}>
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => router.back()}
className="flex items-center space-x-2"
>
<ArrowLeft className="w-4 h-4" />
<span>Back</span>
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">User Details</h1>
<p className="text-gray-600">{user.email}</p>
</div>
</div>
{canEdit && !isEditing && (
<Button
onClick={() => setIsEditing(true)}
className="flex items-center space-x-2"
>
<Edit className="w-4 h-4" />
<span>Edit User</span>
</Button>
)}
</div>
{/* Error Display */}
{error && (
<Card className="p-4 mb-6 bg-red-50 border-red-200">
<div className="flex items-center justify-between">
<p className="text-red-800">{error}</p>
<Button variant="outline" size="sm" onClick={clearError}>
Dismiss
</Button>
</div>
</Card>
)}
{/* Content */}
{isEditing ? (
<UserForm
user={user}
loading={isLoadingUser}
onSubmit={handleEditSubmit}
onCancel={() => setIsEditing(false)}
/>
) : (
<div className="space-y-6">
{/* User Card */}
<UserCard
user={user}
showAdminActions={false}
className="mb-6"
/>
{/* User Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Account Information */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<User className="w-6 h-6 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">Account Information</h3>
</div>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">User ID</dt>
<dd className="text-sm text-gray-900 font-mono">{user.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="text-sm text-gray-900">{user.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Role</dt>
<dd className="text-sm text-gray-900">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.role}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Status</dt>
<dd className="text-sm text-gray-900">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</dd>
</div>
</dl>
</Card>
{/* Account Timeline */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<Calendar className="w-6 h-6 text-green-600" />
<h3 className="text-lg font-semibold text-gray-900">Account Timeline</h3>
</div>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">Created</dt>
<dd className="text-sm text-gray-900">
{new Date(user.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
<dd className="text-sm text-gray-900">
{new Date(user.updatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</dd>
</div>
</dl>
</Card>
</div>
{/* Activity Logs (Placeholder) */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<Shield className="w-6 h-6 text-purple-600" />
<h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3>
</div>
<div className="text-center py-8">
<p className="text-gray-500">
Activity logs will be displayed here in a future update.
</p>
</div>
</Card>
</div>
)}
</div>
</div>
</AuthGuard>
);
}

View File

@@ -0,0 +1,316 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Plus, Search, Filter, ArrowLeft } from 'lucide-react';
import { useUsersStore } from '../../../stores/users-store';
import { UsersTable, UserForm } from '../../../features/shared/components/users';
import { Button } from '../../../features/shared/components/ui/button';
import { Input } from '../../../features/shared/components/ui/input';
import { Card } from '../../../features/shared/components/ui/card';
import { ResponsiveLayout } from '../../../features/shared/components/layout/responsive-layout';
import { AuthGuard } from '../../../features/shared/middleware/auth-guard';
import { useAuthStore } from '../../../stores/auth-store';
import { useRouter } from 'next/navigation';
/**
* EN: Admin Users List Page
* VI: Trang danh sách Users cho Admin
*
* Features:
* - List all users with pagination
* - Search and filter functionality
* - Bulk actions (activate/deactivate/delete)
* - Create new user modal
* - Edit user modal
* - Role-based access control
*/
export default function AdminUsersPage() {
const { user: currentUser } = useAuthStore();
const router = useRouter();
const {
users,
pagination,
isLoading,
error,
fetchUsers,
createUser,
updateUser,
deleteUser,
bulkDeleteUsers,
bulkUpdateUserRoles,
clearError,
} = useUsersStore();
const [searchQuery, setSearchQuery] = useState('');
const [selectedRole, setSelectedRole] = useState<string>('all');
const [selectedStatus, setSelectedStatus] = useState<string>('all');
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<any>(null);
// Check if current user has admin permissions
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
// Load users on mount and when filters change
useEffect(() => {
if (isAdmin) {
const params = {
search: searchQuery || undefined,
role: selectedRole !== 'all' ? selectedRole : undefined,
isActive: selectedStatus !== 'all' ? selectedStatus === 'active' : undefined,
limit: 20,
};
fetchUsers(params);
}
}, [isAdmin, searchQuery, selectedRole, selectedStatus, fetchUsers]);
const handleCreateUser = async (userData: any) => {
try {
await createUser(userData);
setShowCreateModal(false);
clearError();
} catch (error) {
// Error is handled in the store
}
};
const handleEditUser = async (userData: any) => {
if (!editingUser) return;
try {
await updateUser(editingUser.id, userData);
setEditingUser(null);
clearError();
} catch (error) {
// Error is handled in the store
}
};
const handleDeleteUser = async (user: any) => {
if (confirm(`Are you sure you want to delete ${user.email}?`)) {
try {
await deleteUser(user.id);
clearError();
} catch (error) {
// Error is handled in the store
}
}
};
const handleToggleUserStatus = async (user: any) => {
try {
await updateUser(user.id, { isActive: !user.isActive });
clearError();
} catch (error) {
// Error is handled in the store
}
};
const handleBulkAction = async (action: string, userIds: string[]) => {
const actionMessages = {
delete: `Are you sure you want to delete ${userIds.length} user(s)?`,
activate: `Are you sure you want to activate ${userIds.length} user(s)?`,
deactivate: `Are you sure you want to deactivate ${userIds.length} user(s)?`,
};
if (!confirm(actionMessages[action as keyof typeof actionMessages])) {
return;
}
try {
if (action === 'delete') {
await bulkDeleteUsers(userIds);
} else if (action === 'activate' || action === 'deactivate') {
const updates = userIds.map(id => ({
id,
role: users.find(u => u.id === id)?.role || 'USER',
}));
// Note: For status changes, we'd need a bulk update status method
// For now, we'll handle individual updates
for (const userId of userIds) {
const user = users.find(u => u.id === userId);
if (user) {
await updateUser(userId, { isActive: action === 'activate' });
}
}
}
clearError();
} catch (error) {
// Error is handled in the store
}
};
// Desktop Header
const desktopHeader = (
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="flex items-center space-x-2"
>
<ArrowLeft className="w-4 h-4" />
<span>Back</span>
</Button>
<h1 className="text-xl font-semibold text-white">Users Management</h1>
</div>
<Button
onClick={() => setShowCreateModal(true)}
className="flex items-center space-x-2"
>
<Plus className="w-4 h-4" />
<span>Add User</span>
</Button>
</div>
);
// Mobile Header
const mobileHeader = (
<div className="flex items-center justify-between w-full px-4">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="p-2"
>
<ArrowLeft className="w-4 h-4 text-white" />
</Button>
<h1 className="text-lg font-semibold text-white">Users</h1>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateModal(true)}
className="p-2"
>
<Plus className="w-5 h-5 text-white" />
</Button>
</div>
);
const pageContent = (
<div className="p-6 max-w-7xl mx-auto">
{/* Page Title (hidden on mobile, shown in header) */}
<div className="hidden md:block mb-8">
<h1 className="text-3xl font-bold text-gray-900">Users Management</h1>
<p className="text-gray-600 mt-2">
Manage user accounts, roles, and permissions
</p>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
placeholder="Search users..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Roles</option>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
{isSuperAdmin && <option value="SUPER_ADMIN">Super Admin</option>}
</select>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</Card>
{/* Error Display */}
{error && (
<Card className="p-4 mb-6 bg-red-50 border-red-200">
<div className="flex items-center justify-between">
<p className="text-red-800">{error}</p>
<Button variant="outline" size="sm" onClick={clearError}>
Dismiss
</Button>
</div>
</Card>
)}
{/* Users Table */}
<UsersTable
users={users}
loading={isLoading}
onEditUser={setEditingUser}
onDeleteUser={handleDeleteUser}
onToggleUserStatus={handleToggleUserStatus}
onBulkAction={handleBulkAction}
showBulkActions={isSuperAdmin}
/>
{/* Pagination Info */}
{pagination && (
<div className="mt-6 text-center text-gray-600">
Showing {users.length} of {pagination.total} users
(Page {pagination.page} of {pagination.totalPages})
</div>
)}
{/* Create User Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="max-w-md w-full max-h-[90vh] overflow-y-auto">
<UserForm
isCreate
loading={isLoading}
onSubmit={handleCreateUser}
onCancel={() => setShowCreateModal(false)}
/>
</div>
</div>
)}
{/* Edit User Modal */}
{editingUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="max-w-md w-full max-h-[90vh] overflow-y-auto">
<UserForm
user={editingUser}
loading={isLoading}
onSubmit={handleEditUser}
onCancel={() => setEditingUser(null)}
/>
</div>
</div>
)}
</div>
);
return (
<AuthGuard requiredRoles={['ADMIN', 'SUPER_ADMIN']}>
<ResponsiveLayout
header={mobileHeader}
showHeader={true}
enablePullToRefresh={true}
onRefresh={async () => {
await fetchUsers();
}}
>
{pageContent}
</ResponsiveLayout>
</AuthGuard>
);
}

View File

@@ -0,0 +1,332 @@
'use client';
import React, { useState } from 'react';
import { MessageCircle, User, Settings, Home, Search, LogOut, Menu } from 'lucide-react';
import { useAuthStore } from '../../stores/auth-store';
import { ResponsiveLayout } from '../../features/shared/components/layout/responsive-layout';
import { MobileBottomNav, useBottomNav } from '../../features/shared/components/layout/mobile-layout';
import { Button } from '../../features/shared/components/ui/button';
import { Card } from '../../features/shared/components/ui/card';
import { useRouter } from 'next/navigation';
/**
* EN: Dashboard Page - Main authenticated user interface
* VI: Trang Dashboard - Giao diện chính cho người dùng đã xác thực
*
* Features:
* - Responsive layout (desktop sidebar + mobile bottom nav)
* - Quick actions and status overview
* - Navigation to different sections
* - User greeting and avatar
*/
export default function DashboardPage() {
const { user, logout } = useAuthStore();
const router = useRouter();
const { activeItem, handleNavPress } = useBottomNav('home');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
if (!user) {
router.push('/auth/login');
return null;
}
const handleLogout = async () => {
await logout();
router.push('/');
};
// Desktop Header
const desktopHeader = (
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-2"
>
<Menu className="w-5 h-5" />
</Button>
<h1 className="text-xl font-semibold text-white">Dashboard</h1>
</div>
<div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-semibold text-sm">
{user.email[0].toUpperCase()}
</span>
</div>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
</div>
</div>
);
// Mobile Header
const mobileHeader = (
<div className="flex items-center justify-between w-full px-4">
<h1 className="text-lg font-semibold text-white">Dashboard</h1>
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-semibold text-sm">
{user.email[0].toUpperCase()}
</span>
</div>
</div>
</div>
);
// Desktop Sidebar
const desktopSidebar = (
<div className="p-4">
<div className="mb-8">
<div className="flex items-center space-x-3 mb-6">
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-semibold">
{user.email[0].toUpperCase()}
</span>
</div>
{!sidebarCollapsed && (
<div>
<p className="font-medium text-gray-900">{user.email}</p>
<p className="text-sm text-gray-500 capitalize">{user.role.toLowerCase()}</p>
</div>
)}
</div>
{!sidebarCollapsed && (
<nav className="space-y-2">
{[
{ id: 'home', label: 'Home', icon: Home },
{ id: 'chat', label: 'Chat', icon: MessageCircle },
{ id: 'search', label: 'Search', icon: Search },
{ id: 'profile', label: 'Profile', icon: User },
{ id: 'settings', label: 'Settings', icon: Settings },
].map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => {
if (item.id === 'chat') router.push('/chat');
else if (item.id === 'profile') router.push('/profile');
else if (item.id === 'settings') router.push('/settings');
}}
className="w-full flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<Icon className="w-5 h-5 text-gray-600" />
<span className="text-gray-700">{item.label}</span>
</button>
);
})}
</nav>
)}
</div>
{!sidebarCollapsed && (
<div className="border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={handleLogout}
className="w-full flex items-center justify-center"
>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
</div>
)}
</div>
);
// Mobile Bottom Navigation Items
const mobileNavItems = [
{
id: 'home',
label: 'Home',
icon: <Home className="w-6 h-6" />,
},
{
id: 'search',
label: 'Search',
icon: <Search className="w-6 h-6" />,
},
{
id: 'chat',
label: 'Chat',
icon: <MessageCircle className="w-6 h-6" />,
badge: 2,
},
{
id: 'profile',
label: 'Profile',
icon: <User className="w-6 h-6" />,
},
{
id: 'settings',
label: 'Settings',
icon: <Settings className="w-6 h-6" />,
},
];
const handleMobileNavPress = (itemId: string) => {
handleNavPress(itemId);
if (itemId === 'chat') router.push('/chat');
else if (itemId === 'profile') router.push('/profile');
else if (itemId === 'settings') router.push('/settings');
};
// Dashboard Content
const dashboardContent = (
<div className="p-6 max-w-4xl mx-auto">
{/* Welcome Section */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Welcome back, {user.email.split('@')[0]}!
</h2>
<p className="text-gray-600">
Here's what's happening with your account today.
</p>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<Card className="p-6">
<div className="flex items-center">
<div className="p-3 bg-blue-100 rounded-lg">
<MessageCircle className="w-6 h-6 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Active Chats</p>
<p className="text-2xl font-bold text-gray-900">3</p>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center">
<div className="p-3 bg-green-100 rounded-lg">
<User className="w-6 h-6 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Profile Views</p>
<p className="text-2xl font-bold text-gray-900">12</p>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center">
<div className="p-3 bg-purple-100 rounded-lg">
<Settings className="w-6 h-6 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Settings Updated</p>
<p className="text-2xl font-bold text-gray-900">2</p>
</div>
</div>
</Card>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h3>
<div className="space-y-3">
<Button
onClick={() => router.push('/chat')}
className="w-full flex items-center justify-start"
variant="outline"
>
<MessageCircle className="w-4 h-4 mr-3" />
Start New Chat
</Button>
<Button
onClick={() => router.push('/profile')}
className="w-full flex items-center justify-start"
variant="outline"
>
<User className="w-4 h-4 mr-3" />
Update Profile
</Button>
<Button
onClick={() => router.push('/settings')}
className="w-full flex items-center justify-start"
variant="outline"
>
<Settings className="w-4 h-4 mr-3" />
Account Settings
</Button>
</div>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<p className="text-sm text-gray-600">Started a new conversation</p>
<span className="text-xs text-gray-400">2h ago</span>
</div>
<div className="flex items-center space-x-3">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<p className="text-sm text-gray-600">Updated profile information</p>
<span className="text-xs text-gray-400">1d ago</span>
</div>
<div className="flex items-center space-x-3">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
<p className="text-sm text-gray-600">Changed notification settings</p>
<span className="text-xs text-gray-400">3d ago</span>
</div>
</div>
</Card>
</div>
{/* Account Status */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Account Status</h3>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Account Active</p>
<p className="text-sm text-gray-600">
Your account is in good standing. Member since {new Date(user.createdAt).getFullYear()}.
</p>
</div>
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
user.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</div>
</div>
</Card>
</div>
);
return (
<ResponsiveLayout
// Desktop props
header={desktopHeader}
sidebar={desktopSidebar}
showSidebar={true}
sidebarWidth={sidebarCollapsed ? 64 : 280}
sidebarCollapsible={true}
sidebarCollapsed={sidebarCollapsed}
// Mobile props
showBottomNav={true}
bottomNavItems={mobileNavItems}
activeNavItem={activeItem}
onNavItemPress={handleMobileNavPress}
enablePullToRefresh={true}
onRefresh={async () => {
// Simulate refresh
await new Promise(resolve => setTimeout(resolve, 1000));
}}
>
{dashboardContent}
</ResponsiveLayout>
);
}

View File

@@ -2,7 +2,7 @@
import { useAuthStore } from '@/stores/auth-store';
import { useEffect, useState } from 'react';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
import { BrandLogo } from '@/features/shared/components/brand';
import { Button } from '@/ui';
import { Footer } from '@/shared/components/layout/footer';
@@ -16,7 +16,7 @@ import { Search, ArrowRight, Code, Zap, Globe } from 'lucide-react';
*/
export default function Home() {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
// EN: Get authentication state from store
// VI: Lấy trạng thái xác thực từ store

View File

@@ -0,0 +1,335 @@
'use client';
import React, { useState } from 'react';
import { User, Mail, Lock, Bell, Palette, Globe } from 'lucide-react';
import { useAuthStore } from '../../stores/auth-store';
import { UserForm } from '../../features/shared/components/users';
import { Button } from '../../features/shared/components/ui/button';
import { Card } from '../../features/shared/components/ui/card';
import { Switch } from '../../features/shared/components/ui/switch';
/**
* EN: User Profile Page
* VI: Trang Profile của User
*
* Features:
* - View and edit personal information
* - Change password
* - Account settings
* - Preferences management
*/
export default function ProfilePage() {
const { user, isAuthenticated, updateProfile } = useAuthStore();
const [activeTab, setActiveTab] = useState<'profile' | 'security' | 'preferences'>('profile');
const [isEditing, setIsEditing] = useState(false);
// Mock preferences (in real app, this would come from a preferences store)
const [preferences, setPreferences] = useState({
emailNotifications: true,
pushNotifications: false,
darkMode: false,
language: 'en',
});
if (!isAuthenticated || !user) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Please sign in</h2>
<p className="text-gray-600">
You need to be signed in to access your profile.
</p>
</Card>
</div>
);
}
const handleProfileUpdate = async (data: any) => {
try {
// In a real app, this would call an API to update the user profile
console.log('Updating profile:', data);
setIsEditing(false);
// Mock success - in real app you'd update the auth store
} catch (error) {
console.error('Failed to update profile:', error);
}
};
const handlePreferenceChange = (key: string, value: any) => {
setPreferences(prev => ({ ...prev, [key]: value }));
};
const tabs = [
{ id: 'profile', label: 'Profile', icon: User },
{ id: 'security', label: 'Security', icon: Lock },
{ id: 'preferences', label: 'Preferences', icon: Bell },
];
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">My Profile</h1>
<p className="text-gray-600">Manage your account settings and preferences</p>
</div>
{/* Profile Overview Card */}
<Card className="p-6 mb-8">
<div className="flex items-center space-x-6">
<div className="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-bold text-2xl">
{user.email[0].toUpperCase()}
</span>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">{user.email}</h2>
<p className="text-gray-600">Member since {new Date(user.createdAt).getFullYear()}</p>
<div className="flex items-center mt-2">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.role}
</span>
<span className={`ml-3 inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
</div>
</Card>
{/* Tabs */}
<div className="flex space-x-1 mb-6 bg-white p-1 rounded-lg border">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center space-x-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${
activeTab === tab.id
? 'bg-blue-100 text-blue-700 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Icon className="w-4 h-4" />
<span>{tab.label}</span>
</button>
);
})}
</div>
{/* Tab Content */}
<div className="space-y-6">
{/* Profile Tab */}
{activeTab === 'profile' && (
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900">Personal Information</h3>
<p className="text-gray-600">Update your personal details and contact information</p>
</div>
{!isEditing && (
<Button onClick={() => setIsEditing(true)}>
Edit Profile
</Button>
)}
</div>
{isEditing ? (
<UserForm
user={user}
onSubmit={handleProfileUpdate}
onCancel={() => setIsEditing(false)}
/>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<Mail className="w-5 h-5 text-gray-400" />
<span className="text-gray-900">{user.email}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Account Role
</label>
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<User className="w-5 h-5 text-gray-400" />
<span className="text-gray-900 capitalize">{user.role.toLowerCase()}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Account Status
</label>
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<span className={`w-3 h-3 rounded-full ${
user.isActive ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className="text-gray-900">
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Member Since
</label>
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<span className="text-gray-900">
{new Date(user.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
</div>
)}
</Card>
)}
{/* Security Tab */}
{activeTab === 'security' && (
<Card className="p-6">
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900">Security Settings</h3>
<p className="text-gray-600">Manage your password and security preferences</p>
</div>
<div className="space-y-6">
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Change Password</h4>
<div className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Current Password
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter current password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter new password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Confirm new password"
/>
</div>
<Button>Update Password</Button>
</div>
</div>
</div>
</Card>
)}
{/* Preferences Tab */}
{activeTab === 'preferences' && (
<Card className="p-6">
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900">Preferences</h3>
<p className="text-gray-600">Customize your experience and notification settings</p>
</div>
<div className="space-y-6">
<div>
<h4 className="text-md font-medium text-gray-900 mb-4 flex items-center">
<Bell className="w-5 h-5 mr-2" />
Notifications
</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Email Notifications</p>
<p className="text-sm text-gray-600">Receive notifications via email</p>
</div>
<Switch
checked={preferences.emailNotifications}
onCheckedChange={(checked) => handlePreferenceChange('emailNotifications', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Push Notifications</p>
<p className="text-sm text-gray-600">Receive push notifications in your browser</p>
</div>
<Switch
checked={preferences.pushNotifications}
onCheckedChange={(checked) => handlePreferenceChange('pushNotifications', checked)}
/>
</div>
</div>
</div>
<div>
<h4 className="text-md font-medium text-gray-900 mb-4 flex items-center">
<Palette className="w-5 h-5 mr-2" />
Appearance
</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Dark Mode</p>
<p className="text-sm text-gray-600">Use dark theme for the interface</p>
</div>
<Switch
checked={preferences.darkMode}
onCheckedChange={(checked) => handlePreferenceChange('darkMode', checked)}
/>
</div>
</div>
</div>
<div>
<h4 className="text-md font-medium text-gray-900 mb-4 flex items-center">
<Globe className="w-5 h-5 mr-2" />
Language & Region
</h4>
<div className="max-w-xs">
<label className="block text-sm font-medium text-gray-700 mb-2">
Language
</label>
<select
value={preferences.language}
onChange={(e) => handlePreferenceChange('language', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="en">English</option>
<option value="vi">Tiếng Việt</option>
</select>
</div>
</div>
</div>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,315 @@
'use client';
import React, { useState } from 'react';
import {
Settings,
Bell,
Shield,
Palette,
Globe,
User,
Moon,
Sun,
Monitor
} from 'lucide-react';
import { useAuthStore } from '../../stores/auth-store';
import { Button } from '../../features/shared/components/ui/button';
import { Card } from '../../features/shared/components/ui/card';
import { Switch } from '../../features/shared/components/ui/switch';
/**
* EN: Settings Page
* VI: Trang Settings
*
* Features:
* - Theme preferences (light/dark/auto)
* - Language settings
* - Notification preferences
* - Privacy settings
* - Account management
*/
export default function SettingsPage() {
const { user, isAuthenticated, logout } = useAuthStore();
const [settings, setSettings] = useState({
theme: 'system', // 'light', 'dark', 'system'
language: 'en',
emailNotifications: true,
pushNotifications: false,
marketingEmails: false,
profileVisibility: 'private', // 'public', 'private'
dataSharing: false,
});
if (!isAuthenticated || !user) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Please sign in</h2>
<p className="text-gray-600">
You need to be signed in to access settings.
</p>
</Card>
</div>
);
}
const handleSettingChange = (key: string, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const handleSaveSettings = () => {
// In a real app, this would save to backend
console.log('Saving settings:', settings);
// Show success message
};
const handleExportData = () => {
// Mock data export
const data = {
user: user,
settings: settings,
exportDate: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `user-data-${user.id}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleDeleteAccount = () => {
if (confirm('Are you sure you want to delete your account? This action cannot be undone.')) {
// In a real app, this would call delete API
console.log('Deleting account...');
logout();
}
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Settings</h1>
<p className="text-gray-600">Manage your account preferences and privacy settings</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Settings */}
<div className="lg:col-span-2 space-y-6">
{/* Appearance */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-6">
<Palette className="w-6 h-6 text-blue-600" />
<div>
<h3 className="text-lg font-semibold text-gray-900">Appearance</h3>
<p className="text-gray-600">Customize how the app looks and feels</p>
</div>
</div>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Theme
</label>
<div className="grid grid-cols-3 gap-3">
{[
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor },
].map((theme) => {
const Icon = theme.icon;
return (
<button
key={theme.value}
onClick={() => handleSettingChange('theme', theme.value)}
className={`p-4 border rounded-lg text-center transition-all ${
settings.theme === theme.value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Icon className="w-6 h-6 mx-auto mb-2" />
<span className="text-sm font-medium">{theme.label}</span>
</button>
);
})}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Language
</label>
<select
value={settings.language}
onChange={(e) => handleSettingChange('language', e.target.value)}
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="en">English</option>
<option value="vi">Tiếng Việt</option>
<option value="es">Español</option>
<option value="fr">Français</option>
</select>
</div>
</div>
</Card>
{/* Notifications */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-6">
<Bell className="w-6 h-6 text-green-600" />
<div>
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
<p className="text-gray-600">Choose what notifications you want to receive</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Email Notifications</p>
<p className="text-sm text-gray-600">Receive important updates via email</p>
</div>
<Switch
checked={settings.emailNotifications}
onCheckedChange={(checked) => handleSettingChange('emailNotifications', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Push Notifications</p>
<p className="text-sm text-gray-600">Receive notifications in your browser</p>
</div>
<Switch
checked={settings.pushNotifications}
onCheckedChange={(checked) => handleSettingChange('pushNotifications', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Marketing Emails</p>
<p className="text-sm text-gray-600">Receive promotional content and updates</p>
</div>
<Switch
checked={settings.marketingEmails}
onCheckedChange={(checked) => handleSettingChange('marketingEmails', checked)}
/>
</div>
</div>
</Card>
{/* Privacy */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-6">
<Shield className="w-6 h-6 text-purple-600" />
<div>
<h3 className="text-lg font-semibold text-gray-900">Privacy</h3>
<p className="text-gray-600">Control your privacy and data sharing preferences</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Profile Visibility
</label>
<select
value={settings.profileVisibility}
onChange={(e) => handleSettingChange('profileVisibility', e.target.value)}
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="public">Public - Anyone can see my profile</option>
<option value="private">Private - Only I can see my profile</option>
</select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Data Sharing</p>
<p className="text-sm text-gray-600">Allow anonymous usage data to help improve the service</p>
</div>
<Switch
checked={settings.dataSharing}
onCheckedChange={(checked) => handleSettingChange('dataSharing', checked)}
/>
</div>
</div>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Account Info */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<User className="w-6 h-6 text-gray-600" />
<div>
<h3 className="text-lg font-semibold text-gray-900">Account</h3>
</div>
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">Email</p>
<p className="font-medium text-gray-900">{user.email}</p>
</div>
<div>
<p className="text-sm text-gray-600">Role</p>
<p className="font-medium text-gray-900 capitalize">{user.role.toLowerCase()}</p>
</div>
<div>
<p className="text-sm text-gray-600">Status</p>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
</Card>
{/* Actions */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Account Actions</h3>
<div className="space-y-3">
<Button onClick={handleSaveSettings} className="w-full">
Save Settings
</Button>
<Button variant="outline" onClick={handleExportData} className="w-full">
Export My Data
</Button>
<Button variant="outline" onClick={() => window.location.href = '/profile'} className="w-full">
Edit Profile
</Button>
</div>
</Card>
{/* Danger Zone */}
<Card className="p-6 border-red-200 bg-red-50">
<h3 className="text-lg font-semibold text-red-900 mb-4">Danger Zone</h3>
<div className="space-y-3">
<Button
variant="outline"
onClick={handleDeleteAccount}
className="w-full border-red-300 text-red-700 hover:bg-red-50"
>
Delete Account
</Button>
<p className="text-xs text-red-600">
This action cannot be undone. All your data will be permanently deleted.
</p>
</div>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
import { Button } from '@/features/shared/components/ui/button';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: ChatInput component props interface
@@ -94,7 +94,7 @@ export function ChatInput({
className,
}: ChatInputProps) {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
const defaultPlaceholder = placeholder || t('chat.typeMessage');
// EN: Reference to textarea element for auto-resize / VI: Reference đến element textarea cho auto-resize
const textareaRef = React.useRef<HTMLTextAreaElement>(null);

View File

@@ -3,7 +3,7 @@
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
import { Menu, X } from 'lucide-react';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: Chat layout component props interface
@@ -97,7 +97,7 @@ export function ChatLayout({
className,
}: ChatLayoutProps) {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
// EN: Mobile: Hide sidebar by default / VI: Mobile: Ẩn sidebar mặc định
const [mobileSidebarVisible, setMobileSidebarVisible] = React.useState(false);

View File

@@ -6,7 +6,8 @@ import { Button } from '@/features/shared/components/ui/button';
import { Input } from '@/features/shared/components/ui/input';
import { Avatar, AvatarFallback } from '@/features/shared/components/ui/avatar';
import { useAuthStore } from '@/stores/auth-store';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
import { useI18n } from '@/features/theme';
/**
* EN: Conversation interface
@@ -66,7 +67,8 @@ export function ConversationSidebar({
className,
}: ConversationSidebarProps) {
// EN: Translation hook / VI: Hook translation
const { t, locale } = useTranslation();
const t = useTranslations();
const { locale } = useI18n();
// EN: Get current user from auth store / VI: Lấy user hiện tại từ auth store
const { user } = useAuthStore();

View File

@@ -9,7 +9,7 @@ import {
DropdownMenuTrigger,
} from '@/features/shared/components/ui/dropdown-menu';
import { cn } from '@/shared/lib/utils';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: Message role type
@@ -89,7 +89,7 @@ export function MessageActionsMenu({
children,
}: MessageActionsMenuProps) {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
// EN: Copy to clipboard handler / VI: Handler copy vào clipboard
const handleCopy = React.useCallback(async () => {

View File

@@ -3,7 +3,8 @@
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/features/shared/components/ui/avatar';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
import { useI18n } from '@/features/theme';
/**
* EN: Message sender type
@@ -330,7 +331,8 @@ export function MessageBubble({
className,
}: MessageBubbleProps) {
// EN: Translation hook / VI: Hook translation
const { t, locale } = useTranslation();
const t = useTranslations();
const { locale } = useI18n();
// EN: System messages - centered, simple text / VI: Tin nhắn hệ thống - căn giữa, text đơn giản
if (sender === 'system') {

View File

@@ -2,7 +2,7 @@
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useTranslations } from 'next-intl';
/**
* EN: TypingIndicator component props interface
@@ -69,7 +69,7 @@ export function TypingIndicator({
'aria-label': ariaLabel,
}: TypingIndicatorProps) {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
const t = useTranslations();
const defaultAriaLabel = ariaLabel || t('chat.typing', { defaultValue: 'AI is typing...' });
// EN: Generate array of dot indices for rendering / VI: Tạo mảng các chỉ số chấm để render
const dots = React.useMemo(() => {

View File

@@ -0,0 +1,138 @@
'use client';
import React, { useEffect, useState } from 'react';
import { DesktopLayout, DesktopLayoutProps } from './desktop-layout/desktop-layout';
import { MobileLayout, MobileLayoutProps } from './mobile-layout/mobile-layout';
/**
* EN: Responsive Layout Props - combines desktop and mobile layouts
* VI: Responsive Layout Props - kết hợp desktop và mobile layouts
*/
export interface ResponsiveLayoutProps extends DesktopLayoutProps, MobileLayoutProps {
/** EN: Force desktop layout even on mobile / VI: Buộc desktop layout ngay cả trên mobile */
forceDesktop?: boolean;
/** EN: Force mobile layout even on desktop / VI: Buộc mobile layout ngay cả trên desktop */
forceMobile?: boolean;
}
/**
* EN: Responsive Layout Component - automatically switches between desktop and mobile layouts
* VI: Responsive Layout Component - tự động chuyển đổi giữa desktop và mobile layouts
*
* Features:
* - Automatically detects screen size
* - Uses DesktopLayout for desktop/tablet landscape
* - Uses MobileLayout for mobile/tablet portrait
* - Supports force overrides for testing
* - Handles all props for both layout types
*
* @example
* ```tsx
* <ResponsiveLayout
* showSidebar
* sidebar={<Sidebar />}
* showBottomNav
* bottomNavItems={navItems}
* >
* <YourContent />
* </ResponsiveLayout>
* ```
*/
export function ResponsiveLayout({
forceDesktop = false,
forceMobile = false,
// Desktop props
header,
sidebar,
footer,
showSidebar = false,
showHeader = true,
showFooter = false,
sidebarPosition = 'left',
sidebarWidth = 280,
sidebarCollapsible = false,
sidebarCollapsed = false,
// Mobile props
bottomNav,
enablePullToRefresh = false,
onRefresh,
showBottomNav = false,
bottomNavItems = [],
activeNavItem,
onNavItemPress,
// Common props
children,
className,
}: ResponsiveLayoutProps) {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (forceDesktop) {
setIsMobile(false);
return;
}
if (forceMobile) {
setIsMobile(true);
return;
}
// Check screen size
const checkMobile = () => {
// Consider mobile if screen width < 768px (md breakpoint)
// or if device is touch-based and screen height > width (portrait mobile)
const isSmallScreen = window.innerWidth < 768;
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const isPortrait = window.innerHeight > window.innerWidth;
setIsMobile(isSmallScreen || (isTouchDevice && isPortrait));
};
checkMobile();
window.addEventListener('resize', checkMobile);
window.addEventListener('orientationchange', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
window.removeEventListener('orientationchange', checkMobile);
};
}, [forceDesktop, forceMobile]);
if (isMobile && !forceDesktop) {
return (
<MobileLayout
header={header}
footer={footer}
bottomNav={bottomNav}
showHeader={showHeader}
showFooter={showFooter}
enablePullToRefresh={enablePullToRefresh}
onRefresh={onRefresh}
showBottomNav={showBottomNav}
bottomNavItems={bottomNavItems}
activeNavItem={activeNavItem}
onNavItemPress={onNavItemPress}
className={className}
>
{children}
</MobileLayout>
);
}
return (
<DesktopLayout
header={header}
sidebar={sidebar}
footer={footer}
showSidebar={showSidebar}
showHeader={showHeader}
showFooter={showFooter}
sidebarPosition={sidebarPosition}
sidebarWidth={sidebarWidth}
sidebarCollapsible={sidebarCollapsible}
sidebarCollapsed={sidebarCollapsed}
className={className}
>
{children}
</DesktopLayout>
);
}

View File

@@ -0,0 +1,231 @@
'use client';
import React from 'react';
import { UserResponse, Role } from '@goodgo/types';
import { Mail, Calendar, Shield, Edit, MoreHorizontal } from 'lucide-react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { DropdownMenu } from '../ui/dropdown-menu';
import { Switch } from '../ui/switch';
import { cn } from '@/shared/lib/utils';
/**
* EN: Props for UserCard component
* VI: Props cho component UserCard
*/
export interface UserCardProps {
/** EN: User data to display / VI: Dữ liệu user để hiển thị */
user: UserResponse;
/** EN: Whether the card is in compact mode / VI: Card có ở chế độ compact không */
compact?: boolean;
/** EN: Callback when user is edited / VI: Callback khi user được edit */
onEdit?: (user: UserResponse) => void;
/** EN: Callback when user status is toggled / VI: Callback khi toggle trạng thái user */
onToggleStatus?: (user: UserResponse) => void;
/** EN: Callback when user is deleted / VI: Callback khi user bị xóa */
onDelete?: (user: UserResponse) => void;
/** EN: Show admin actions (edit, delete, status toggle) / VI: Hiển thị admin actions */
showAdminActions?: boolean;
/** EN: Additional CSS classes / VI: CSS classes bổ sung */
className?: string;
}
/**
* EN: User Card Component - Displays user information in a card format
* VI: Component User Card - Hiển thị thông tin user dưới dạng card
*
* Features:
* - User avatar (email initial)
* - Basic user info (email, role, status)
* - Creation/update dates
* - Admin actions (edit, delete, toggle status)
* - Compact and full modes
*/
export function UserCard({
user,
compact = false,
onEdit,
onToggleStatus,
onDelete,
showAdminActions = false,
className,
}: UserCardProps) {
const getRoleColor = (role: Role) => {
switch (role) {
case Role.SUPER_ADMIN:
return 'bg-red-100 text-red-800 border-red-200';
case Role.ADMIN:
return 'bg-blue-100 text-blue-800 border-blue-200';
case Role.USER:
return 'bg-gray-100 text-gray-800 border-gray-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getStatusColor = (isActive: boolean) => {
return isActive
? 'text-green-600 bg-green-100'
: 'text-gray-600 bg-gray-100';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (compact) {
return (
<Card className={cn('p-4 hover:shadow-md transition-shadow', className)}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{/* Avatar */}
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-semibold text-sm">
{user.email[0].toUpperCase()}
</span>
</div>
{/* User Info */}
<div>
<div className="flex items-center space-x-2">
<span className="font-medium text-gray-900">{user.email}</span>
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border',
getRoleColor(user.role)
)}>
{user.role}
</span>
</div>
<div className="flex items-center space-x-2 text-sm text-gray-500">
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs',
getStatusColor(user.isActive)
)}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
<span>Created {formatDate(user.createdAt)}</span>
</div>
</div>
</div>
{/* Actions */}
{showAdminActions && (
<DropdownMenu
trigger={
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
}
items={[
{
label: 'Edit',
icon: <Edit className="w-4 h-4" />,
onClick: () => onEdit?.(user),
},
{
label: 'Delete',
icon: <div className="w-4 h-4 bg-red-500 rounded-full" />,
onClick: () => onDelete?.(user),
destructive: true,
},
]}
/>
)}
</div>
</Card>
);
}
return (
<Card className={cn('p-6', className)}>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
{/* Avatar */}
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-bold text-xl">
{user.email[0].toUpperCase()}
</span>
</div>
{/* User Info */}
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900">{user.email}</h3>
<span className={cn(
'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium border',
getRoleColor(user.role)
)}>
<Shield className="w-4 h-4 mr-1" />
{user.role}
</span>
</div>
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-3">
<div className="flex items-center space-x-1">
<Mail className="w-4 h-4" />
<span>{user.email}</span>
</div>
<div className="flex items-center space-x-1">
<Calendar className="w-4 h-4" />
<span>Created {formatDate(user.createdAt)}</span>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<Switch
checked={user.isActive}
onCheckedChange={() => onToggleStatus?.(user)}
disabled={!showAdminActions}
/>
<span className={cn(
'text-sm font-medium',
user.isActive ? 'text-green-600' : 'text-gray-500'
)}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<span className="text-sm text-gray-500">
Updated {formatDate(user.updatedAt)}
</span>
</div>
</div>
</div>
{/* Actions */}
{showAdminActions && (
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => onEdit?.(user)}
>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
<DropdownMenu
trigger={
<Button variant="outline" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
}
items={[
{
label: 'Delete',
icon: <div className="w-4 h-4 bg-red-500 rounded-full" />,
onClick: () => onDelete?.(user),
destructive: true,
},
]}
/>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,311 @@
'use client';
import React, { useState, useEffect } from 'react';
import { UserResponse, CreateUserDto, UpdateUserDto, Role } from '@goodgo/types';
import { User, Mail, Shield, Save, X } from 'lucide-react';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
import { Select } from '../ui/select';
import { Switch } from '../ui/switch';
import { cn } from '@/shared/lib/utils';
/**
* EN: Props for UserForm component
* VI: Props cho component UserForm
*/
export interface UserFormProps {
/** EN: User to edit (null for create mode) / VI: User để edit (null cho create mode) */
user?: UserResponse | null;
/** EN: Whether this is create mode / VI: Có phải create mode không */
isCreate?: boolean;
/** EN: Loading state / VI: Trạng thái loading */
loading?: boolean;
/** EN: Callback when form is submitted / VI: Callback khi form được submit */
onSubmit: (data: CreateUserDto | UpdateUserDto) => Promise<void>;
/** EN: Callback when form is cancelled / VI: Callback khi form bị cancel */
onCancel?: () => void;
/** EN: Additional CSS classes / VI: CSS classes bổ sung */
className?: string;
}
/**
* EN: User Form Component - Create and edit user forms
* VI: Component User Form - Forms tạo và edit user
*
* Features:
* - Create new user form
* - Edit existing user form
* - Form validation
* - Role selection
* - Password fields for creation
* - Active status toggle
*/
export function UserForm({
user,
isCreate = false,
loading = false,
onSubmit,
onCancel,
className,
}: UserFormProps) {
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
role: Role.USER,
isActive: true,
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Initialize form data when user prop changes
useEffect(() => {
if (user) {
setFormData({
email: user.email,
password: '',
confirmPassword: '',
role: user.role,
isActive: user.isActive,
});
} else {
setFormData({
email: '',
password: '',
confirmPassword: '',
role: Role.USER,
isActive: true,
});
}
}, [user]);
const handleInputChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error for this field
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
// Email validation
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Please enter a valid email address';
}
// Password validation (only for create mode)
if (isCreate) {
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
if (isCreate) {
const createData: CreateUserDto = {
email: formData.email,
password: formData.password,
role: formData.role,
};
await onSubmit(createData);
} else {
const updateData: UpdateUserDto = {
email: formData.email,
role: formData.role,
isActive: formData.isActive,
};
await onSubmit(updateData);
}
} catch (error) {
// Error handling is done in the parent component
}
};
const roleOptions = [
{ value: Role.USER, label: 'User' },
{ value: Role.ADMIN, label: 'Admin' },
{ value: Role.SUPER_ADMIN, label: 'Super Admin' },
];
return (
<Card className={cn('p-6', className)}>
<div className="flex items-center space-x-3 mb-6">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-blue-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">
{isCreate ? 'Create New User' : 'Edit User'}
</h2>
<p className="text-sm text-gray-600">
{isCreate ? 'Add a new user to the system' : 'Update user information and permissions'}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
className={cn(
'pl-10',
errors.email && 'border-red-300 focus:border-red-500 focus:ring-red-500'
)}
placeholder="user@example.com"
disabled={loading}
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
{/* Password Fields (Create Mode Only) */}
{isCreate && (
<>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
className={cn(
errors.password && 'border-red-300 focus:border-red-500 focus:ring-red-500'
)}
placeholder="Enter password"
disabled={loading}
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
Confirm Password
</label>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
className={cn(
errors.confirmPassword && 'border-red-300 focus:border-red-500 focus:ring-red-500'
)}
placeholder="Confirm password"
disabled={loading}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
)}
</div>
</>
)}
{/* Role Selection */}
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-2">
Role
</label>
<div className="relative">
<Shield className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 z-10" />
<Select
value={formData.role}
onValueChange={(value) => handleInputChange('role', value as Role)}
disabled={loading}
>
{roleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
</div>
{/* Active Status (Edit Mode Only) */}
{!isCreate && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h4 className="text-sm font-medium text-gray-900">Account Status</h4>
<p className="text-sm text-gray-600">
{formData.isActive ? 'User can access the system' : 'User is deactivated'}
</p>
</div>
<Switch
checked={formData.isActive}
onCheckedChange={(checked) => handleInputChange('isActive', checked)}
disabled={loading}
/>
</div>
)}
{/* Form Actions */}
<div className="flex justify-end space-x-3 pt-6 border-t">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
)}
<Button
type="submit"
disabled={loading}
className="min-w-[120px]"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
{isCreate ? 'Creating...' : 'Saving...'}
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
{isCreate ? 'Create User' : 'Save Changes'}
</>
)}
</Button>
</div>
</form>
</Card>
);
}

View File

@@ -0,0 +1,300 @@
'use client';
import React, { useState } from 'react';
import { UserResponse, Role } from '@goodgo/types';
import { MoreHorizontal, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { DropdownMenu } from '../ui/dropdown-menu';
import { Switch } from '../ui/switch';
import { cn } from '@/shared/lib/utils';
/**
* EN: Props for UsersTable component
* VI: Props cho component UsersTable
*/
export interface UsersTableProps {
/** EN: Array of users to display / VI: Mảng users để hiển thị */
users: UserResponse[];
/** EN: Loading state / VI: Trạng thái loading */
loading?: boolean;
/** EN: Callback when user is selected for editing / VI: Callback khi user được chọn để edit */
onEditUser?: (user: UserResponse) => void;
/** EN: Callback when user deletion is requested / VI: Callback khi yêu cầu xóa user */
onDeleteUser?: (user: UserResponse) => void;
/** EN: Callback when user active status is toggled / VI: Callback khi toggle trạng thái active của user */
onToggleUserStatus?: (user: UserResponse) => void;
/** EN: Callback when bulk actions are performed / VI: Callback khi thực hiện bulk actions */
onBulkAction?: (action: 'delete' | 'activate' | 'deactivate', userIds: string[]) => void;
/** EN: Whether to show bulk actions / VI: Có hiển thị bulk actions không */
showBulkActions?: boolean;
/** EN: Additional CSS classes / VI: CSS classes bổ sung */
className?: string;
}
/**
* EN: Users Table Component - Displays users in a data table with actions
* VI: Component Users Table - Hiển thị users trong data table với actions
*
* Features:
* - Sortable columns
* - Bulk selection and actions
* - Individual user actions (edit, delete, toggle status)
* - Responsive design
* - Loading states
*/
export function UsersTable({
users,
loading = false,
onEditUser,
onDeleteUser,
onToggleUserStatus,
onBulkAction,
showBulkActions = true,
className,
}: UsersTableProps) {
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
const [sortField, setSortField] = useState<'email' | 'role' | 'createdAt' | 'isActive'>('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
// Sort users based on current sort settings
const sortedUsers = React.useMemo(() => {
return [...users].sort((a, b) => {
let aValue: any = a[sortField];
let bValue: any = b[sortField];
if (sortField === 'createdAt' || sortField === 'updatedAt') {
aValue = new Date(aValue).getTime();
bValue = new Date(bValue).getTime();
}
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}, [users, sortField, sortDirection]);
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedUsers(new Set(users.map(user => user.id)));
} else {
setSelectedUsers(new Set());
}
};
const handleSelectUser = (userId: string, checked: boolean) => {
const newSelected = new Set(selectedUsers);
if (checked) {
newSelected.add(userId);
} else {
newSelected.delete(userId);
}
setSelectedUsers(newSelected);
};
const handleBulkAction = (action: 'delete' | 'activate' | 'deactivate') => {
if (selectedUsers.size > 0) {
onBulkAction?.(action, Array.from(selectedUsers));
setSelectedUsers(new Set());
}
};
const getRoleBadgeColor = (role: Role) => {
switch (role) {
case Role.SUPER_ADMIN:
return 'bg-red-100 text-red-800 border-red-200';
case Role.ADMIN:
return 'bg-blue-100 text-blue-800 border-blue-200';
case Role.USER:
return 'bg-gray-100 text-gray-800 border-gray-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
if (loading) {
return (
<Card className={cn('p-6', className)}>
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Loading users...</span>
</div>
</Card>
);
}
return (
<div className={cn('space-y-4', className)}>
{/* Bulk Actions Bar */}
{showBulkActions && selectedUsers.size > 0 && (
<Card className="p-4 bg-blue-50 border-blue-200">
<div className="flex items-center justify-between">
<span className="text-sm text-blue-800">
{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''} selected
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleBulkAction('activate')}
className="text-green-700 border-green-300 hover:bg-green-50"
>
<UserCheck className="w-4 h-4 mr-2" />
Activate
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkAction('deactivate')}
className="text-orange-700 border-orange-300 hover:bg-orange-50"
>
<UserX className="w-4 h-4 mr-2" />
Deactivate
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkAction('delete')}
className="text-red-700 border-red-300 hover:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
</div>
</Card>
)}
{/* Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
{showBulkActions && (
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={selectedUsers.size === users.length && users.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
className="rounded border-gray-300"
/>
</th>
)}
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('email')}
>
Email {sortField === 'email' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('role')}
>
Role {sortField === 'role' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('isActive')}
>
Status {sortField === 'isActive' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('createdAt')}
>
Created {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
{showBulkActions && (
<td className="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedUsers.has(user.id)}
onChange={(e) => handleSelectUser(user.id, e.target.checked)}
className="rounded border-gray-300"
/>
</td>
)}
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
getRoleBadgeColor(user.role)
)}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Switch
checked={user.isActive}
onCheckedChange={() => onToggleUserStatus?.(user)}
className="mr-2"
/>
<span className={cn(
'text-sm',
user.isActive ? 'text-green-600' : 'text-gray-400'
)}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<DropdownMenu
trigger={
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
}
items={[
{
label: 'Edit',
icon: <Edit className="w-4 h-4" />,
onClick: () => onEditUser?.(user),
},
{
label: 'Delete',
icon: <Trash2 className="w-4 h-4" />,
onClick: () => onDeleteUser?.(user),
destructive: true,
},
]}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{sortedUsers.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500">No users found</p>
</div>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,13 @@
/**
* EN: Users Components Exports
* VI: Exports cho Users Components
*/
export { UsersTable } from './UsersTable';
export type { UsersTableProps } from './UsersTable';
export { UserCard } from './UserCard';
export type { UserCardProps } from './UserCard';
export { UserForm } from './UserForm';
export type { UserFormProps } from './UserForm';

View File

@@ -1,81 +0,0 @@
'use client';
/**
* EN: Custom translation hook
* VI: Hook translation tùy chỉnh
*/
import { useI18n } from '@/features/theme';
import enMessages from '../i18n/en.json';
import viMessages from '../i18n/vi.json';
/**
* EN: Custom hook for translations with locale management
* VI: Hook tùy chỉnh cho translations với quản lý locale
*
* @example
* ```tsx
* const t = useTranslation();
* const saveText = t('common.save');
* const loginTitle = t('auth.login.title');
* ```
*/
export function useTranslation() {
const { locale, setLocale } = useI18n();
// EN: Get messages based on current locale
// VI: Lấy messages dựa trên locale hiện tại
const messages = locale === 'vi' ? viMessages : enMessages;
/**
* EN: Translation function that supports nested keys and interpolation
* VI: Hàm translation hỗ trợ nested keys và interpolation
*/
const t = (key: string, values?: Record<string, any>): string => {
const keys = key.split('.');
let value: any = messages;
// EN: Navigate through nested object
// VI: Điều hướng qua nested object
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
// EN: Return key if translation not found (fallback)
// VI: Trả về key nếu không tìm thấy translation (fallback)
console.warn(`Translation missing for key: ${key} in locale: ${locale}`);
return key;
}
}
// EN: Return the translation if it's a string
// VI: Trả về translation nếu là string
if (typeof value === 'string') {
// EN: Simple interpolation for {variable} placeholders
// VI: Interpolation đơn giản cho placeholders {variable}
if (values) {
return Object.entries(values).reduce((str, [key, val]) => {
return str.replace(new RegExp(`{${key}}`, 'g'), String(val));
}, value);
}
return value;
}
return key;
};
return {
/**
* EN: Translation function / VI: Hàm translation
*/
t,
/**
* EN: Current locale / VI: Locale hiện tại
*/
locale,
/**
* EN: Set locale function / VI: Hàm đặt locale
*/
setLocale,
};
}

View File

@@ -0,0 +1,211 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '../../../stores/auth-store';
import { Role } from '@goodgo/types';
import { Card } from '../components/ui/card';
import { Button } from '../ui/button';
/**
* EN: Auth Guard Props
* VI: Props cho Auth Guard
*/
interface AuthGuardProps {
/** EN: Content to render when authenticated / VI: Nội dung để render khi đã xác thực */
children: React.ReactNode;
/** EN: Required authentication / VI: Yêu cầu xác thực */
requireAuth?: boolean;
/** EN: Required roles for access / VI: Vai trò yêu cầu để truy cập */
requiredRoles?: Role[];
/** EN: Redirect path when not authenticated / VI: Đường dẫn redirect khi chưa xác thực */
redirectTo?: string;
/** EN: Fallback component while checking auth / VI: Component fallback trong khi kiểm tra auth */
fallback?: React.ReactNode;
/** EN: Whether to check on client side only / VI: Có chỉ kiểm tra ở client side không */
clientOnly?: boolean;
}
/**
* EN: Auth Guard Component - Protects routes based on authentication and role requirements
* VI: Auth Guard Component - Bảo vệ routes dựa trên xác thực và yêu cầu vai trò
*
* Features:
* - Client-side authentication checking
* - Role-based access control
* - Automatic redirects
* - Loading states
* - Custom fallback components
*
* @example
* ```tsx
* // Require authentication
* <AuthGuard>
* <ProtectedContent />
* </AuthGuard>
*
* // Require specific role
* <AuthGuard requiredRoles={[Role.ADMIN]}>
* <AdminContent />
* </AuthGuard>
*
* // Require multiple roles
* <AuthGuard requiredRoles={[Role.ADMIN, Role.SUPER_ADMIN]}>
* <SuperAdminContent />
* </AuthGuard>
* ```
*/
export function AuthGuard({
children,
requireAuth = true,
requiredRoles = [],
redirectTo,
fallback,
clientOnly = true,
}: AuthGuardProps) {
const router = useRouter();
const { user, isAuthenticated, isLoading, fetchUser } = useAuthStore();
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
// If clientOnly is true, skip server-side checks
if (clientOnly) {
setIsChecking(false);
return;
}
// Fetch user data if not loaded
if (!user && !isLoading) {
fetchUser().finally(() => setIsChecking(false));
} else {
setIsChecking(false);
}
}, [user, isLoading, fetchUser, clientOnly]);
// Show loading state
if (isChecking || isLoading) {
if (fallback) {
return <>{fallback}</>;
}
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
// Check authentication requirement
if (requireAuth && !isAuthenticated) {
// Redirect to login or custom path
const redirectPath = redirectTo || '/auth/login';
if (typeof window !== 'undefined') {
router.push(redirectPath);
}
return null;
}
// Check role requirements
if (requiredRoles.length > 0 && user) {
const hasRequiredRole = requiredRoles.includes(user.role);
if (!hasRequiredRole) {
// Show access denied
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="p-8 text-center max-w-md">
<h2 className="text-2xl font-bold text-red-600 mb-4">Access Denied</h2>
<p className="text-gray-600 mb-6">
You don't have permission to access this resource.
</p>
<div className="flex gap-4 justify-center">
<Button onClick={() => router.back()}>
Go Back
</Button>
<Button variant="outline" onClick={() => router.push('/dashboard')}>
Dashboard
</Button>
</div>
</Card>
</div>
);
}
}
// All checks passed, render children
return <>{children}</>;
}
/**
* EN: Require Auth HOC - Higher-order component for authentication
* VI: Require Auth HOC - Higher-order component cho xác thực
*
* @example
* ```tsx
* const ProtectedPage = requireAuth(MyComponent);
* const AdminPage = requireAuth(MyComponent, [Role.ADMIN]);
* ```
*/
export function requireAuth<P extends object>(
Component: React.ComponentType<P>,
requiredRoles?: Role[]
) {
return function AuthenticatedComponent(props: P) {
return (
<AuthGuard requiredRoles={requiredRoles}>
<Component {...props} />
</AuthGuard>
);
};
}
/**
* EN: Role-based guard components
* VI: Components guard dựa trên vai trò
*/
export const RequireAdmin: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<AuthGuard requiredRoles={[Role.ADMIN, Role.SUPER_ADMIN]}>
{children}
</AuthGuard>
);
export const RequireSuperAdmin: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<AuthGuard requiredRoles={[Role.SUPER_ADMIN]}>
{children}
</AuthGuard>
);
/**
* EN: Utility functions for role checking
* VI: Hàm utility để kiểm tra vai trò
*/
export const hasRole = (userRole: Role | undefined, requiredRoles: Role[]): boolean => {
if (!userRole) return false;
return requiredRoles.includes(userRole);
};
export const hasAdminRole = (userRole: Role | undefined): boolean => {
return hasRole(userRole, [Role.ADMIN, Role.SUPER_ADMIN]);
};
export const hasSuperAdminRole = (userRole: Role | undefined): boolean => {
return hasRole(userRole, [Role.SUPER_ADMIN]);
};
export const canManageUsers = (userRole: Role | undefined): boolean => {
return hasRole(userRole, [Role.ADMIN, Role.SUPER_ADMIN]);
};
export const canDeleteUsers = (currentUserRole: Role | undefined, targetUserRole: Role): boolean => {
if (!currentUserRole) return false;
// Super admin can delete anyone
if (currentUserRole === Role.SUPER_ADMIN) return true;
// Admin can delete users but not other admins or super admins
if (currentUserRole === Role.ADMIN) {
return targetUserRole === Role.USER;
}
// Users cannot delete anyone
return false;
};

View File

@@ -9,7 +9,7 @@ import {
DropdownMenuItem
} from '@/features/shared/components/ui/dropdown-menu';
import { cn } from '@/shared/lib/utils';
import { useTranslation } from '@/shared/hooks/use-translation';
import { useI18n } from '../i18n-context';
/**
* EN: Language configuration

View File

@@ -15,6 +15,12 @@ export const locales = ['en', 'vi'] as const;
*/
export const defaultLocale = 'en' as const;
/**
* EN: Default timezone for consistent date/time formatting
* VI: Timezone mặc định để format date/time nhất quán
*/
export const defaultTimeZone = 'UTC' as const;
/**
* EN: Locale type
* VI: Kiểu locale

View File

@@ -31,7 +31,7 @@ interface I18nContextType {
* EN: i18n Context
* VI: Context i18n
*/
const I18nContext = React.createContext<I18nContextType | undefined>(undefined);
export const I18nContext = React.createContext<I18nContextType | undefined>(undefined);
/**
* EN: Get locale from localStorage or browser

View File

@@ -8,30 +8,44 @@
*/
import { NextIntlClientProvider } from 'next-intl';
import { I18nProvider as CustomI18nProvider } from './i18n-context';
import { useI18n } from './i18n-context';
import { useMemo } from 'react';
import { I18nContext } from './i18n-context';
import { defaultTimeZone } from './i18n-config';
import * as React from 'react';
import enMessages from '../shared/i18n/en.json';
import viMessages from '../shared/i18n/vi.json';
/**
* EN: Inner provider that uses the locale from context
* VI: Provider bên trong sử dụng locale từ context
* EN: Get locale from localStorage or browser (duplicate from i18n-context to avoid circular dependency)
* VI: Lấy locale từ localStorage hoặc browser (duplicate từ i18n-context để tránh circular dependency)
*/
function NextIntlProviderWrapper({ children }: { children: React.ReactNode }) {
const { locale } = useI18n();
function getStoredLocale(): 'en' | 'vi' {
if (typeof window === 'undefined') {
return 'en'; // defaultLocale
}
// EN: Get messages based on locale - use static imports for immediate availability / VI: Lấy messages dựa trên locale - sử dụng static imports để có sẵn ngay
const messages = useMemo(() => {
return locale === 'vi' ? viMessages : enMessages;
}, [locale]);
// EN: Try to get from localStorage preferences / VI: Thử lấy từ localStorage preferences
try {
const stored = localStorage.getItem('preferences');
if (stored) {
const parsed = JSON.parse(stored);
if (parsed.language && (parsed.language === 'en' || parsed.language === 'vi')) {
return parsed.language;
}
}
} catch {
// EN: Invalid stored data, continue to browser detection / VI: Dữ liệu lưu không hợp lệ, tiếp tục detect browser
}
// EN: Always render NextIntlClientProvider to ensure context exists / VI: Luôn render NextIntlClientProvider để đảm bảo context tồn tại
return (
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
);
// EN: Detect from browser language / VI: Phát hiện từ ngôn ngữ browser
if (typeof navigator !== 'undefined') {
const browserLang = navigator.language || navigator.languages?.[0] || '';
const langCode = browserLang.split('-')[0].toLowerCase();
if (langCode === 'vi') {
return 'vi';
}
}
return 'en'; // defaultLocale
}
/**
@@ -39,9 +53,75 @@ function NextIntlProviderWrapper({ children }: { children: React.ReactNode }) {
* VI: Component I18n Provider chính
*/
export function I18nProvider({ children }: { children: React.ReactNode }) {
// EN: Get initial locale / VI: Lấy locale ban đầu
const [locale, setLocaleState] = React.useState<'en' | 'vi'>(() => getStoredLocale());
/**
* EN: Set locale and persist to localStorage
* VI: Đặt locale và lưu vào localStorage
*/
const setLocale = React.useCallback((newLocale: 'en' | 'vi') => {
if (newLocale !== 'en' && newLocale !== 'vi') {
console.warn(`Invalid locale: ${newLocale}`);
return;
}
setLocaleState(newLocale);
// EN: Update localStorage preferences / VI: Cập nhật localStorage preferences
if (typeof window !== 'undefined') {
try {
const stored = localStorage.getItem('preferences');
const preferences = stored ? JSON.parse(stored) : {};
preferences.language = newLocale;
localStorage.setItem('preferences', JSON.stringify(preferences));
} catch (error) {
console.error('Failed to save locale preference:', error);
}
// EN: Update HTML lang attribute / VI: Cập nhật thuộc tính lang của HTML
document.documentElement.lang = newLocale;
}
}, []);
/**
* EN: Get current locale
* VI: Lấy locale hiện tại
*/
const getLocale = React.useCallback(() => {
return locale;
}, [locale]);
// EN: Initialize HTML lang attribute on mount / VI: Khởi tạo thuộc tính lang của HTML khi mount
React.useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
}
}, [locale]);
// EN: Get messages based on locale / VI: Lấy messages dựa trên locale
const messages = React.useMemo(() => {
return locale === 'vi' ? viMessages : enMessages;
}, [locale]);
const customContextValue = React.useMemo(
() => ({
locale,
setLocale,
getLocale,
}),
[locale, setLocale, getLocale]
);
return (
<CustomI18nProvider>
<NextIntlProviderWrapper>{children}</NextIntlProviderWrapper>
</CustomI18nProvider>
<I18nContext.Provider value={customContextValue}>
<NextIntlClientProvider
locale={locale}
messages={messages}
timeZone={defaultTimeZone}
>
{children}
</NextIntlClientProvider>
</I18nContext.Provider>
);
}

View File

@@ -0,0 +1,125 @@
import { UserResponse, CreateUserDto, UpdateUserDto, Role } from '@goodgo/types';
import { apiClient } from '../../services/api/client';
/**
* EN: Query parameters for users list endpoint
* VI: Tham số query cho endpoint danh sách users
*/
export interface GetUsersParams {
/** EN: Page number for pagination / VI: Số trang cho pagination */
page?: number;
/** EN: Number of items per page / VI: Số items mỗi trang */
limit?: number;
/** EN: Search query for filtering users / VI: Query tìm kiếm để lọc users */
search?: string;
/** EN: Filter by user role / VI: Lọc theo vai trò user */
role?: Role;
/** EN: Filter by active status / VI: Lọc theo trạng thái active */
isActive?: boolean;
/** EN: Sort field / VI: Trường sắp xếp */
sortBy?: 'email' | 'createdAt' | 'updatedAt';
/** EN: Sort direction / VI: Hướng sắp xếp */
sortOrder?: 'asc' | 'desc';
}
/**
* EN: Response structure for paginated users list
* VI: Cấu trúc response cho danh sách users phân trang
*/
export interface GetUsersResponse {
/** EN: Array of user objects / VI: Mảng các objects user */
data: UserResponse[];
/** EN: Pagination metadata / VI: Metadata phân trang */
pagination: {
/** EN: Current page number / VI: Số trang hiện tại */
page: number;
/** EN: Items per page / VI: Items mỗi trang */
limit: number;
/** EN: Total number of items / VI: Tổng số items */
total: number;
/** EN: Total number of pages / VI: Tổng số trang */
totalPages: number;
};
}
/**
* EN: Fetch paginated list of users
* VI: Lấy danh sách users phân trang
*
* @param params - Query parameters for filtering and pagination
* @returns Promise resolving to paginated users response
*/
export async function getUsers(params: GetUsersParams = {}): Promise<GetUsersResponse> {
const response = await apiClient.get('/users', { params });
return response.data;
}
/**
* EN: Fetch single user by ID
* VI: Lấy thông tin user theo ID
*
* @param id - User ID
* @returns Promise resolving to user response
*/
export async function getUser(id: string): Promise<UserResponse> {
const response = await apiClient.get(`/users/${id}`);
return response.data;
}
/**
* EN: Create new user
* VI: Tạo user mới
*
* @param payload - User creation data
* @returns Promise resolving to created user response
*/
export async function createUser(payload: CreateUserDto): Promise<UserResponse> {
const response = await apiClient.post('/users', payload);
return response.data;
}
/**
* EN: Update existing user
* VI: Cập nhật user hiện có
*
* @param id - User ID
* @param payload - User update data
* @returns Promise resolving to updated user response
*/
export async function updateUser(id: string, payload: UpdateUserDto): Promise<UserResponse> {
const response = await apiClient.put(`/users/${id}`, payload);
return response.data;
}
/**
* EN: Delete user by ID
* VI: Xóa user theo ID
*
* @param id - User ID
* @returns Promise resolving when deletion is complete
*/
export async function deleteUser(id: string): Promise<void> {
await apiClient.delete(`/users/${id}`);
}
/**
* EN: Bulk delete multiple users
* VI: Xóa nhiều users cùng lúc
*
* @param ids - Array of user IDs to delete
* @returns Promise resolving when bulk deletion is complete
*/
export async function bulkDeleteUsers(ids: string[]): Promise<void> {
await apiClient.post('/users/bulk-delete', { ids });
}
/**
* EN: Bulk update user roles
* VI: Cập nhật vai trò cho nhiều users cùng lúc
*
* @param updates - Array of user ID and new role pairs
* @returns Promise resolving when bulk update is complete
*/
export async function bulkUpdateUserRoles(updates: Array<{ id: string; role: Role }>): Promise<void> {
await apiClient.post('/users/bulk-update-roles', { updates });
}

View File

@@ -0,0 +1,306 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useUsersStore } from '../users-store';
// Mock the API functions
vi.mock('../../lib/api/users', () => ({
getUsers: vi.fn(),
getUser: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
bulkDeleteUsers: vi.fn(),
bulkUpdateUserRoles: vi.fn(),
}));
import {
getUsers,
getUser,
createUser,
updateUser,
deleteUser,
bulkDeleteUsers,
bulkUpdateUserRoles,
} from '../../lib/api/users';
/**
* EN: Users store unit tests
* VI: Unit tests cho users store
*/
describe('UsersStore', () => {
beforeEach(() => {
// Reset store state before each test
useUsersStore.setState({
users: [],
currentUser: null,
pagination: null,
isLoading: false,
isLoadingUser: false,
error: null,
});
// Reset all mocks
vi.clearAllMocks();
});
describe('initial state', () => {
it('initializes with default state', () => {
const state = useUsersStore.getState();
expect(state.users).toEqual([]);
expect(state.currentUser).toBeNull();
expect(state.pagination).toBeNull();
expect(state.isLoading).toBe(false);
expect(state.isLoadingUser).toBe(false);
expect(state.error).toBeNull();
});
});
describe('fetchUsers', () => {
it('sets loading state and fetches users successfully', async () => {
const mockResponse = {
data: [
{
id: '1',
email: 'user1@example.com',
role: 'USER',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
],
pagination: {
page: 1,
limit: 10,
total: 1,
totalPages: 1,
},
};
(getUsers as any).mockResolvedValue(mockResponse);
const { fetchUsers } = useUsersStore.getState();
await fetchUsers();
const state = useUsersStore.getState();
expect(state.isLoading).toBe(false);
expect(state.users).toEqual(mockResponse.data);
expect(state.pagination).toEqual(mockResponse.pagination);
expect(state.error).toBeNull();
});
it('handles fetch users error', async () => {
const mockError = new Error('Failed to fetch users');
(getUsers as any).mockRejectedValue(mockError);
const { fetchUsers } = useUsersStore.getState();
await expect(fetchUsers()).rejects.toThrow('Failed to fetch users');
const state = useUsersStore.getState();
expect(state.isLoading).toBe(false);
expect(state.users).toEqual([]);
expect(state.error).toBe('Failed to fetch users');
});
});
describe('fetchUser', () => {
it('fetches single user successfully', async () => {
const mockUser = {
id: '1',
email: 'user1@example.com',
role: 'USER',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
(getUser as any).mockResolvedValue(mockUser);
const { fetchUser } = useUsersStore.getState();
await fetchUser('1');
const state = useUsersStore.getState();
expect(state.isLoadingUser).toBe(false);
expect(state.currentUser).toEqual(mockUser);
expect(state.error).toBeNull();
});
it('handles fetch user error', async () => {
const mockError = new Error('User not found');
(getUser as any).mockRejectedValue(mockError);
const { fetchUser } = useUsersStore.getState();
await expect(fetchUser('1')).rejects.toThrow('User not found');
const state = useUsersStore.getState();
expect(state.isLoadingUser).toBe(false);
expect(state.currentUser).toBeNull();
expect(state.error).toBe('User not found');
});
});
describe('createUser', () => {
it('creates user successfully and adds to list', async () => {
const newUser = {
id: '3',
email: 'user3@example.com',
role: 'USER',
isActive: true,
createdAt: '2024-01-03T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
};
const existingUsers = [
{
id: '1',
email: 'user1@example.com',
role: 'USER',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: '2',
email: 'user2@example.com',
role: 'USER',
isActive: true,
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
// Set initial state with existing users
useUsersStore.setState({ users: existingUsers });
(createUser as any).mockResolvedValue(newUser);
const createData = {
email: 'user3@example.com',
password: 'password123',
role: 'USER' as const,
};
const { createUser: createUserAction } = useUsersStore.getState();
const result = await createUserAction(createData);
expect(result).toEqual(newUser);
const state = useUsersStore.getState();
expect(state.isLoadingUser).toBe(false);
expect(state.users).toHaveLength(3);
expect(state.users[0]).toEqual(newUser); // New user added at beginning
});
});
describe('updateUser', () => {
it('updates user successfully', async () => {
const existingUser = {
id: '1',
email: 'user1@example.com',
role: 'USER',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
const updatedUser = {
...existingUser,
email: 'updated@example.com',
role: 'ADMIN',
};
useUsersStore.setState({
users: [existingUser],
currentUser: existingUser,
});
(updateUser as any).mockResolvedValue(updatedUser);
const updateData = {
email: 'updated@example.com',
role: 'ADMIN' as const,
isActive: true,
};
const { updateUser: updateUserAction } = useUsersStore.getState();
const result = await updateUserAction('1', updateData);
expect(result).toEqual(updatedUser);
const state = useUsersStore.getState();
expect(state.isLoadingUser).toBe(false);
expect(state.users[0]).toEqual(updatedUser);
expect(state.currentUser).toEqual(updatedUser);
});
});
describe('deleteUser', () => {
it('deletes user successfully', async () => {
const userToDelete = {
id: '2',
email: 'user2@example.com',
role: 'USER',
isActive: true,
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
};
const users = [
{
id: '1',
email: 'user1@example.com',
role: 'USER',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
userToDelete,
];
useUsersStore.setState({ users, currentUser: userToDelete });
(deleteUser as any).mockResolvedValue(undefined);
const { deleteUser: deleteUserAction } = useUsersStore.getState();
await deleteUserAction('2');
const state = useUsersStore.getState();
expect(state.isLoadingUser).toBe(false);
expect(state.users).toHaveLength(1);
expect(state.users[0].id).toBe('1');
expect(state.currentUser).toBeNull();
});
});
describe('error handling', () => {
it('clears error state', () => {
useUsersStore.setState({ error: 'Test error' });
const { clearError } = useUsersStore.getState();
clearError();
const state = useUsersStore.getState();
expect(state.error).toBeNull();
});
it('resets store to initial state', () => {
useUsersStore.setState({
users: [{ id: '1', email: 'test@example.com', role: 'USER', isActive: true, createdAt: '', updatedAt: '' }],
currentUser: { id: '1', email: 'test@example.com', role: 'USER', isActive: true, createdAt: '', updatedAt: '' },
pagination: { page: 1, limit: 10, total: 1, totalPages: 1 },
isLoading: true,
isLoadingUser: true,
error: 'Test error',
});
const { reset } = useUsersStore.getState();
reset();
const state = useUsersStore.getState();
expect(state.users).toEqual([]);
expect(state.currentUser).toBeNull();
expect(state.pagination).toBeNull();
expect(state.isLoading).toBe(false);
expect(state.isLoadingUser).toBe(false);
expect(state.error).toBeNull();
});
});
});

View File

@@ -0,0 +1,317 @@
import { UserResponse, CreateUserDto, UpdateUserDto, Role } from '@goodgo/types';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import {
getUsers,
getUser,
createUser,
updateUser,
deleteUser,
bulkDeleteUsers,
bulkUpdateUserRoles,
GetUsersParams,
GetUsersResponse
} from '../lib/api/users';
/**
* EN: Users store state interface
* VI: Interface trạng thái users store
*/
interface UsersState {
/** EN: Paginated users list / VI: Danh sách users phân trang */
users: UserResponse[];
/** EN: Single user data for detail view / VI: Dữ liệu user đơn lẻ cho view chi tiết */
currentUser: UserResponse | null;
/** EN: Pagination metadata / VI: Metadata phân trang */
pagination: GetUsersResponse['pagination'] | null;
/** EN: Loading state for list operations / VI: Trạng thái loading cho operations list */
isLoading: boolean;
/** EN: Loading state for single user operations / VI: Trạng thái loading cho operations single user */
isLoadingUser: boolean;
/** EN: Error message if any operation fails / VI: Thông báo lỗi nếu có operation thất bại */
error: string | null;
/** EN: Fetch paginated users list / VI: Lấy danh sách users phân trang */
fetchUsers: (params?: GetUsersParams) => Promise<void>;
/** EN: Fetch single user by ID / VI: Lấy user đơn lẻ theo ID */
fetchUser: (id: string) => Promise<void>;
/** EN: Create new user / VI: Tạo user mới */
createUser: (payload: CreateUserDto) => Promise<UserResponse>;
/** EN: Update existing user / VI: Cập nhật user hiện có */
updateUser: (id: string, payload: UpdateUserDto) => Promise<UserResponse>;
/** EN: Delete user by ID / VI: Xóa user theo ID */
deleteUser: (id: string) => Promise<void>;
/** EN: Bulk delete multiple users / VI: Xóa nhiều users cùng lúc */
bulkDeleteUsers: (ids: string[]) => Promise<void>;
/** EN: Bulk update user roles / VI: Cập nhật vai trò cho nhiều users */
bulkUpdateUserRoles: (updates: Array<{ id: string; role: Role }>) => Promise<void>;
/** EN: Clear current user data / VI: Xóa dữ liệu current user */
clearCurrentUser: () => void;
/** EN: Clear error state / VI: Xóa trạng thái lỗi */
clearError: () => void;
/** EN: Reset store to initial state / VI: Reset store về trạng thái ban đầu */
reset: () => void;
}
/**
* EN: Initial state for users store
* VI: Trạng thái ban đầu cho users store
*/
const initialState = {
users: [],
currentUser: null,
pagination: null,
isLoading: false,
isLoadingUser: false,
error: null,
};
/**
* EN: Zustand store for users state management
* VI: Zustand store để quản lý trạng thái users
*
* Features:
* - Paginated users list management
* - Single user operations (CRUD)
* - Bulk operations support
* - Loading and error states
* - DevTools integration for debugging
*/
export const useUsersStore = create<UsersState>()(
devtools(
(set, get) => ({
...initialState,
/**
* EN: Fetch paginated users list
* VI: Lấy danh sách users phân trang
*/
fetchUsers: async (params = {}) => {
set({ isLoading: true, error: null });
try {
const response = await getUsers(params);
set({
users: response.data,
pagination: response.pagination,
isLoading: false,
});
} catch (error: any) {
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to fetch users';
set({
error: errorMessage,
isLoading: false,
users: [],
pagination: null,
});
throw error;
}
},
/**
* EN: Fetch single user by ID
* VI: Lấy user đơn lẻ theo ID
*/
fetchUser: async (id: string) => {
set({ isLoadingUser: true, error: null });
try {
const user = await getUser(id);
set({
currentUser: user,
isLoadingUser: false,
});
} catch (error: any) {
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to fetch user';
set({
error: errorMessage,
isLoadingUser: false,
currentUser: null,
});
throw error;
}
},
/**
* EN: Create new user
* VI: Tạo user mới
*/
createUser: async (payload: CreateUserDto) => {
set({ isLoadingUser: true, error: null });
try {
const newUser = await createUser(payload);
// Add to users list if we have one
const { users, pagination } = get();
if (users.length > 0 && pagination) {
set({
users: [newUser, ...users.slice(0, -1)], // Add to beginning, remove last to maintain page size
isLoadingUser: false,
});
} else {
set({ isLoadingUser: false });
}
return newUser;
} catch (error: any) {
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create user';
set({
error: errorMessage,
isLoadingUser: false,
});
throw error;
}
},
/**
* EN: Update existing user
* VI: Cập nhật user hiện có
*/
updateUser: async (id: string, payload: UpdateUserDto) => {
set({ isLoadingUser: true, error: null });
try {
const updatedUser = await updateUser(id, payload);
// Update in users list if present
const { users } = get();
const updatedUsers = users.map(user =>
user.id === id ? updatedUser : user
);
set({
users: updatedUsers,
currentUser: updatedUser,
isLoadingUser: false,
});
return updatedUser;
} catch (error: any) {
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to update user';
set({
error: errorMessage,
isLoadingUser: false,
});
throw error;
}
},
/**
* EN: Delete user by ID
* VI: Xóa user theo ID
*/
deleteUser: async (id: string) => {
set({ isLoadingUser: true, error: null });
try {
await deleteUser(id);
// Remove from users list
const { users } = get();
const filteredUsers = users.filter(user => user.id !== id);
set({
users: filteredUsers,
currentUser: null,
isLoadingUser: false,
});
} catch (error: any) {
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to delete user';
set({
error: errorMessage,
isLoadingUser: false,
});
throw error;
}
},
/**
* EN: Bulk delete multiple users
* VI: Xóa nhiều users cùng lúc
*/
bulkDeleteUsers: async (ids: string[]) => {
set({ isLoading: true, error: null });
try {
await bulkDeleteUsers(ids);
// Remove from users list
const { users } = get();
const filteredUsers = users.filter(user => !ids.includes(user.id));
set({
users: filteredUsers,
isLoading: false,
});
} catch (error: any) {
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to bulk delete users';
set({
error: errorMessage,
isLoading: false,
});
throw error;
}
},
/**
* EN: Bulk update user roles
* VI: Cập nhật vai trò cho nhiều users
*/
bulkUpdateUserRoles: async (updates: Array<{ id: string; role: Role }>) => {
set({ isLoading: true, error: null });
try {
await bulkUpdateUserRoles(updates);
// Update in users list
const { users } = get();
const updatedUsers = users.map(user => {
const update = updates.find(u => u.id === user.id);
return update ? { ...user, role: update.role } : user;
});
set({
users: updatedUsers,
isLoading: false,
});
} catch (error: any) {
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to bulk update user roles';
set({
error: errorMessage,
isLoading: false,
});
throw error;
}
},
/**
* EN: Clear current user data
* VI: Xóa dữ liệu current user
*/
clearCurrentUser: () => {
set({ currentUser: null });
},
/**
* EN: Clear error state
* VI: Xóa trạng thái lỗi
*/
clearError: () => {
set({ error: null });
},
/**
* EN: Reset store to initial state
* VI: Reset store về trạng thái ban đầu
*/
reset: () => {
set(initialState);
},
}),
{
name: 'users-store', // DevTools store name
}
)
);