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

@@ -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>
);
}