Update dependencies and enhance Tailwind CSS configuration for web applications

- Added new dependencies including clsx, lucide-react, recharts, and various Radix UI components to improve UI functionality.
- Upgraded Tailwind CSS to version 4.0.0 and updated configuration to utilize CSS variables for theming and responsive design.
- Introduced global styles and improved accessibility features in the layout and components.
- Removed outdated login page and refactored authentication store for better state management.
- Enhanced API service with additional authentication methods and improved error handling.

These changes aim to modernize the web applications and improve user experience through better design and functionality.
This commit is contained in:
Ho Ngoc Hai
2026-01-02 09:41:40 +07:00
parent af303eaf7b
commit c088de53c3
130 changed files with 20618 additions and 415 deletions

View File

@@ -0,0 +1,149 @@
'use client';
import * as React from 'react';
import { AnalyticsCard } from '@/components/admin/analytics-card';
import { RecentActivityTable, type RecentActivity } from '@/components/admin/recent-activity-table';
// EN: Lazy load heavy chart components / VI: Lazy load các component chart nặng
const UserGrowthChart = React.lazy(() => import('@/components/admin/charts/user-growth-chart').then(m => ({ default: m.UserGrowthChart })));
const RevenueChart = React.lazy(() => import('@/components/admin/charts/revenue-chart').then(m => ({ default: m.RevenueChart })));
import { Users, MessageSquare, TrendingUp, DollarSign } from 'lucide-react';
/**
* EN: Dashboard overview page component
* VI: Component trang tổng quan Dashboard
*
* Features:
* - Metrics row with key statistics
* - Charts row (User Growth, Revenue)
* - Recent activity table
*
* Tính năng:
* - Hàng metrics với các thống kê chính
* - Hàng charts (Tăng trưởng người dùng, Doanh thu)
* - Bảng hoạt động gần đây
*/
export default function DashboardPage() {
// EN: Mock data - replace with actual API calls / VI: Dữ liệu mock - thay thế bằng API calls thực tế
const [activities] = React.useState<RecentActivity[]>([
{
id: '1',
user: {
id: 'user1',
name: 'John Doe',
email: 'john@example.com',
},
action: 'user_created',
description: 'Created new user account',
status: 'success',
timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago
},
{
id: '2',
user: {
id: 'user2',
name: 'Jane Smith',
email: 'jane@example.com',
},
action: 'message_sent',
description: 'Sent message in conversation',
status: 'info',
timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago
},
]);
return (
<div className="space-y-6">
{/* EN: Page header / VI: Header trang */}
<div>
<h1 className="text-3xl font-bold text-text-primary">
Dashboard / Bảng điều khiển
</h1>
<p className="mt-1 text-sm text-text-tertiary">
Overview of your platform metrics and activities / Tổng quan về các metric hoạt đng của nền tảng
</p>
</div>
{/* EN: Metrics row / VI: Hàng metrics */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<AnalyticsCard
title="Total Users / Tổng người dùng"
value={12543}
change="+12.5%"
trend="up"
icon={Users}
variant="metric"
/>
<AnalyticsCard
title="Messages / Tin nhắn"
value={89234}
change="+8.2%"
trend="up"
icon={MessageSquare}
variant="metric"
/>
<AnalyticsCard
title="Active Users / Người dùng hoạt động"
value={3421}
change="+5.1%"
trend="up"
icon={TrendingUp}
variant="metric"
/>
<AnalyticsCard
title="Revenue / Doanh thu"
value="$45,231"
change="-2.3%"
trend="down"
icon={DollarSign}
variant="metric"
/>
</div>
{/* EN: Charts row / VI: Hàng charts */}
{/* EN: Tablet: Side-by-side charts / VI: Tablet: Charts cạnh nhau */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 sm:gap-6 lg:grid-cols-2">
{/* EN: User Growth Chart / VI: User Growth Chart */}
<React.Suspense fallback={<div className="h-[300px] bg-bg-secondary rounded-lg border border-border-primary flex items-center justify-center"><div className="text-text-tertiary">Loading chart... / Đang tải chart...</div></div>}>
<UserGrowthChart
data={[
{ date: 'Jan', users: 1000, newUsers: 150 },
{ date: 'Feb', users: 1200, newUsers: 200 },
{ date: 'Mar', users: 1450, newUsers: 250 },
{ date: 'Apr', users: 1700, newUsers: 250 },
{ date: 'May', users: 1950, newUsers: 250 },
{ date: 'Jun', users: 2200, newUsers: 250 },
]}
title="User Growth / Tăng trưởng người dùng"
description="Monthly user growth trend / Xu hướng tăng trưởng người dùng hàng tháng"
/>
</React.Suspense>
{/* EN: Revenue Chart / VI: Revenue Chart */}
<React.Suspense fallback={<div className="h-[300px] bg-bg-secondary rounded-lg border border-border-primary flex items-center justify-center"><div className="text-text-tertiary">Loading chart... / Đang tải chart...</div></div>}>
<RevenueChart
data={[
{ date: 'Jan', revenue: 45000, previousRevenue: 40000 },
{ date: 'Feb', revenue: 52000, previousRevenue: 45000 },
{ date: 'Mar', revenue: 48000, previousRevenue: 52000 },
{ date: 'Apr', revenue: 55000, previousRevenue: 48000 },
{ date: 'May', revenue: 60000, previousRevenue: 55000 },
{ date: 'Jun', revenue: 65000, previousRevenue: 60000 },
]}
title="Revenue / Doanh thu"
description="Monthly revenue trend / Xu hướng doanh thu hàng tháng"
currency="$"
/>
</React.Suspense>
</div>
{/* EN: Recent Activity Table / VI: Bảng hoạt động gần đây */}
<RecentActivityTable
activities={activities}
currentPage={1}
itemsPerPage={10}
onPageChange={(page) => console.log('Page changed:', page)}
onQuickAction={(id, action) => console.log('Action:', id, action)}
/>
</div>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
Users,
BarChart3,
MessageSquare,
Settings,
LogOut,
Menu,
X,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth.store';
/**
* EN: Admin navigation items configuration
* VI: Cấu hình các mục điều hướng Admin
*/
const adminNavItems = [
{
id: 'dashboard',
label: 'Dashboard / Bảng điều khiển',
href: '/dashboard',
icon: LayoutDashboard,
},
{
id: 'users',
label: 'Users / Người dùng',
href: '/dashboard/users',
icon: Users,
},
{
id: 'analytics',
label: 'Analytics / Phân tích',
href: '/dashboard/analytics',
icon: BarChart3,
},
{
id: 'messages',
label: 'Messages / Tin nhắn',
href: '/dashboard/messages',
icon: MessageSquare,
},
{
id: 'settings',
label: 'Settings / Cài đặt',
href: '/dashboard/settings',
icon: Settings,
},
];
/**
* EN: Admin Dashboard layout component with sidebar navigation
* VI: Component layout Admin Dashboard với điều hướng sidebar
*
* Features:
* - Sidebar navigation with icons
* - Responsive design (collapsible on mobile)
* - User profile section
* - Logout functionality
* - Active route highlighting
*
* Tính năng:
* - Điều hướng sidebar với icons
* - Responsive design (có thể thu gọn trên mobile)
* - Phần profile người dùng
* - Chức năng logout
* - Highlight route đang active
*/
export default function AdminDashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const { user, logout } = useAuthStore();
const [sidebarOpen, setSidebarOpen] = React.useState(false);
/**
* EN: Handle logout
* VI: Xử lý logout
*/
const handleLogout = async () => {
await logout();
window.location.href = '/login';
};
return (
<div className="flex h-screen w-full overflow-hidden bg-bg-primary">
{/* EN: Mobile sidebar overlay / VI: Overlay sidebar mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* EN: Sidebar / VI: Sidebar */}
<aside
className={cn(
// EN: Base sidebar styles / VI: Style sidebar cơ bản
'flex flex-col bg-bg-secondary border-r border-border-primary transition-all duration-[250ms] ease-out',
// EN: Desktop: Fixed width / VI: Desktop: Chiều rộng cố định
'w-64 flex-shrink-0',
// EN: Mobile: Fixed position, slide in/out / VI: Mobile: Vị trí cố định, trượt vào/ra
'max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50',
'max-md:transform max-md:transition-transform',
sidebarOpen ? 'max-md:translate-x-0' : 'max-md:-translate-x-full'
)}
aria-label="Admin sidebar / Sidebar Admin"
>
{/* EN: Sidebar header with mobile menu button / VI: Header sidebar với nút menu mobile */}
<div className="flex items-center justify-between p-4 border-b border-border-primary">
<h1 className="text-xl font-semibold text-text-primary">
GoodGo Admin / Quản trị GoodGo
</h1>
<button
onClick={() => setSidebarOpen(false)}
className="md:hidden p-2 rounded-md hover:bg-bg-tertiary transition-colors"
aria-label="Close sidebar / Đóng sidebar"
>
<X className="h-5 w-5 text-text-secondary" />
</button>
</div>
{/* EN: Navigation menu / VI: Menu điều hướng */}
<nav className="flex-1 overflow-y-auto p-4 space-y-1">
{adminNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.id}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={cn(
// EN: Base nav item styles / VI: Style nav item cơ bản
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-[150ms]',
// EN: Active state / VI: Trạng thái active
isActive
? 'bg-accent-primary text-white shadow-md'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary',
)}
aria-current={isActive ? 'page' : undefined}
>
<Icon
className={cn(
'h-5 w-5 flex-shrink-0',
isActive ? 'text-white' : 'text-text-tertiary',
)}
aria-hidden="true"
/>
<span>{item.label}</span>
</Link>
);
})}
</nav>
{/* EN: User profile section / VI: Phần profile người dùng */}
{user && (
<div className="p-4 border-t border-border-primary">
<div className="flex items-center gap-3 mb-3">
<div className="h-10 w-10 rounded-full bg-chat-ai-bubble flex items-center justify-center">
<span className="text-sm font-medium text-text-primary">
{user.email?.charAt(0).toUpperCase() || 'A'}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
{user.email}
</p>
<p className="text-xs text-text-tertiary truncate">
{user.role || 'Admin / Quản trị viên'}
</p>
</div>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-text-secondary hover:bg-bg-tertiary hover:text-text-primary transition-all duration-[150ms]"
aria-label="Logout / Đăng xuất"
>
<LogOut className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
<span>Logout / Đăng xuất</span>
</button>
</div>
)}
</aside>
{/* EN: Main content area / VI: Khu vực nội dung chính */}
<main className="flex-1 overflow-y-auto">
{/* EN: Mobile header with menu button / VI: Header mobile với nút menu */}
<header className="sticky top-0 z-30 flex items-center justify-between p-4 bg-bg-secondary border-b border-border-primary md:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-md hover:bg-bg-tertiary transition-colors"
aria-label="Open sidebar / Mở sidebar"
>
<Menu className="h-5 w-5 text-text-secondary" />
</button>
<h1 className="text-lg font-semibold text-text-primary">
GoodGo Admin / Quản trị GoodGo
</h1>
<div className="w-9" /> {/* EN: Spacer for centering / VI: Khoảng trống để căn giữa */}
</header>
{/* EN: Page content / VI: Nội dung trang */}
<div className="p-6">{children}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import * as React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
// EN: Lazy load settings forms / VI: Lazy load các form cài đặt
const GeneralSettings = React.lazy(() => import('@/components/admin/settings/general-settings').then(m => ({ default: m.GeneralSettings })));
const EmailSettings = React.lazy(() => import('@/components/admin/settings/email-settings').then(m => ({ default: m.EmailSettings })));
const SecuritySettings = React.lazy(() => import('@/components/admin/settings/security-settings').then(m => ({ default: m.SecuritySettings })));
/**
* EN: System Settings page component
* VI: Component trang cài đặt hệ thống
*
* Features:
* - Tab navigation (General, Email, Security, API, Advanced)
* - Settings forms for each category
*
* Tính năng:
* - Điều hướng tab (General, Email, Security, API, Advanced)
* - Forms cài đặt cho mỗi danh mục
*/
export default function SystemSettingsPage() {
return (
<div className="space-y-6">
{/* EN: Page header / VI: Header trang */}
<div>
<h1 className="text-3xl font-bold text-text-primary">
System Settings / Cài đt hệ thống
</h1>
<p className="mt-1 text-sm text-text-tertiary">
Configure system-wide settings and preferences / Cấu hình cài đt tùy chọn toàn hệ thống
</p>
</div>
{/* EN: Settings tabs / VI: Tabs cài đặt */}
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="general">General / Chung</TabsTrigger>
<TabsTrigger value="email">Email / Email</TabsTrigger>
<TabsTrigger value="security">Security / Bảo mật</TabsTrigger>
<TabsTrigger value="api">API / API</TabsTrigger>
<TabsTrigger value="advanced">Advanced / Nâng cao</TabsTrigger>
</TabsList>
{/* EN: General settings tab / VI: Tab cài đặt chung */}
<TabsContent value="general" className="space-y-6">
<React.Suspense fallback={<div className="p-8 text-center"><div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" /><p className="mt-4 text-sm text-text-tertiary">Loading... / Đang tải...</p></div>}>
<GeneralSettings />
</React.Suspense>
</TabsContent>
{/* EN: Email settings tab / VI: Tab cài đặt email */}
<TabsContent value="email" className="space-y-6">
<React.Suspense fallback={<div className="p-8 text-center"><div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" /><p className="mt-4 text-sm text-text-tertiary">Loading... / Đang tải...</p></div>}>
<EmailSettings />
</React.Suspense>
</TabsContent>
{/* EN: Security settings tab / VI: Tab cài đặt bảo mật */}
<TabsContent value="security" className="space-y-6">
<React.Suspense fallback={<div className="p-8 text-center"><div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" /><p className="mt-4 text-sm text-text-tertiary">Loading... / Đang tải...</p></div>}>
<SecuritySettings />
</React.Suspense>
</TabsContent>
{/* EN: API settings tab / VI: Tab cài đặt API */}
<TabsContent value="api" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>API Settings / Cài đt API</CardTitle>
<CardDescription>
Configure API settings and webhooks / Cấu hình cài đt API webhooks
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-text-tertiary">
API settings form will be implemented here / Form cài đt API sẽ đưc implement đây
</p>
</CardContent>
</Card>
</TabsContent>
{/* EN: Advanced settings tab / VI: Tab cài đặt nâng cao */}
<TabsContent value="advanced" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Advanced Settings / Cài đt nâng cao</CardTitle>
<CardDescription>
Advanced system configuration / Cấu hình hệ thống nâng cao
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-text-tertiary">
Advanced settings form will be implemented here / Form cài đt nâng cao sẽ đưc implement đây
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { DataTable, type DataTableColumn } from '@/components/admin/data-table';
// EN: Lazy load modal component / VI: Lazy load component modal
const UserDetailsModal = React.lazy(() => import('@/components/admin/user-details-modal').then(m => ({ default: m.UserDetailsModal })));
import type { User } from '@/components/admin/user-details-modal';
import { Search, Filter, Download, MoreVertical } from 'lucide-react';
/**
* EN: User Management page component
* VI: Component trang quản lý người dùng
*
* Features:
* - Search bar
* - Filter dropdowns
* - User list table (will use DataTable component)
* - Export functionality
*
* Tính năng:
* - Thanh tìm kiếm
* - Dropdowns lọc
* - Bảng danh sách người dùng (sẽ sử dụng component DataTable)
* - Chức năng export
*/
export default function UserManagementPage() {
const [searchQuery, setSearchQuery] = React.useState('');
const [selectedRole, setSelectedRole] = React.useState<string>('all');
const [selectedStatus, setSelectedStatus] = React.useState<string>('all');
return (
<div className="space-y-6">
{/* EN: Page header / VI: Header trang */}
<div>
<h1 className="text-3xl font-bold text-text-primary">
User Management / Quản người dùng
</h1>
<p className="mt-1 text-sm text-text-tertiary">
Manage users, roles, and permissions / Quản người dùng, vai trò quyền
</p>
</div>
{/* EN: Search and filters card / VI: Card tìm kiếm và lọc */}
<Card>
<CardHeader>
<CardTitle>Search & Filters / Tìm kiếm & Lọc</CardTitle>
<CardDescription>
Find and filter users by name, email, role, or status / Tìm lọc người dùng theo tên, email, vai trò hoặc trạng thái
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{/* EN: Search input / VI: Input tìm kiếm */}
<div className="md:col-span-2">
<Input
type="search"
placeholder="Search by name or email... / Tìm kiếm theo tên hoặc email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
</div>
{/* EN: Filter buttons / VI: Nút lọc */}
<div className="flex gap-2">
<Button variant="secondary" size="md" className="flex-1">
<Filter className="h-4 w-4 mr-2" />
Filters / Lọc
</Button>
<Button variant="secondary" size="md">
<Download className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* EN: User list table / VI: Bảng danh sách người dùng */}
<DataTable
data={users}
columns={columns}
currentPage={currentPage}
itemsPerPage={10}
onPageChange={setCurrentPage}
selectable
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
getRowId={(row) => row.id}
bulkActions={[
{
label: 'Activate / Kích hoạt',
action: handleBulkActivate,
variant: 'primary',
},
{
label: 'Deactivate / Vô hiệu hóa',
action: handleBulkDeactivate,
variant: 'secondary',
},
{
label: 'Delete / Xóa',
action: handleBulkDelete,
variant: 'danger',
},
]}
exportable
onExport={() => console.log('Export users')}
/>
{/* EN: User details modal / VI: Modal chi tiết người dùng */}
{selectedUser && (
<React.Suspense fallback={null}>
<UserDetailsModal
user={selectedUser}
open={!!selectedUser}
onOpenChange={(open) => !open && setSelectedUser(null)}
onEdit={(user) => {
console.log('Edit user:', user);
// EN: TODO: Implement edit / VI: TODO: Implement edit
}}
onDelete={(userId) => {
console.log('Delete user:', userId);
setSelectedUser(null);
// EN: TODO: Implement delete / VI: TODO: Implement delete
}}
onDeactivate={(userId) => {
console.log('Deactivate user:', userId);
setSelectedUser(null);
// EN: TODO: Implement deactivate / VI: TODO: Implement deactivate
}}
/>
</React.Suspense>
)}
</div>
);
}

View File

@@ -1,27 +1,199 @@
/**
* EN: Global Styles with Tailwind CSS 4
* VI: Styles toàn cục với Tailwind CSS 4
*
* Import theme variables first, then Tailwind directives
* Import các biến theme trước, sau đó là các directives của Tailwind
*/
@import "../styles/theme.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/**
* EN: Theme CSS Variables - Dark Mode (Default)
* VI: CSS Variables cho Theme - Dark Mode (Mặc định)
*/
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
/* Background Colors */
--bg-primary: #0A0A0A;
--bg-secondary: #121212;
--bg-tertiary: #1A1A1A;
--bg-elevated: #242424;
/* Text Colors */
--text-primary: #FAFAFA;
--text-secondary: #E0E0E0;
--text-tertiary: #A0A0A0;
--text-inverse: #1A1A1A;
/* Brand/Accent Colors */
--accent-primary: #3B82F6;
--accent-secondary: #8B5CF6;
--accent-success: #10B981;
--accent-warning: #F59E0B;
--accent-error: #EF4444;
--accent-info: #06B6D4;
/* Chat Specific Colors */
--chat-user-bubble: #2563EB;
--chat-ai-bubble: #374151;
--chat-user-text: #FFFFFF;
--chat-ai-text: #F3F4F6;
--chat-timestamp: #9CA3AF;
--chat-divider: #1F2937;
/* Border Colors */
--border-primary: #2A2A2A;
--border-secondary: #3A3A3A;
--border-focus: #3B82F6;
/* Font Stack */
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
/* Type Scale */
--text-6xl: 3.75rem;
--text-5xl: 3rem;
--text-4xl: 2.25rem;
--text-3xl: 1.875rem;
--text-2xl: 1.5rem;
--text-xl: 1.25rem;
--text-lg: 1.125rem;
--text-base: 1rem;
--text-sm: 0.875rem;
--text-xs: 0.75rem;
/* Font Weights */
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Spacing Scale (Base: 4px) */
--space-0: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
/* Container Widths */
--container-sm: 640px;
--container-md: 768px;
--container-lg: 1024px;
--container-xl: 1280px;
--container-2xl: 1536px;
--chat-max-width: 768px;
--sidebar-width: 280px;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.6);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.7);
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.3);
/* Animation Timing */
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Duration */
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 350ms;
--duration-slower: 500ms;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
/**
* EN: Light Mode Theme Variables
* VI: CSS Variables cho Light Mode
*/
:root.light {
/* Background Colors */
--bg-primary: #FFFFFF;
--bg-secondary: #F9FAFB;
--bg-tertiary: #F3F4F6;
--bg-elevated: #FFFFFF;
/* Text Colors */
--text-primary: #111827;
--text-secondary: #4B5563;
--text-tertiary: #9CA3AF;
--text-inverse: #FAFAFA;
/* Chat Specific Colors */
--chat-user-bubble: #3B82F6;
--chat-ai-bubble: #F3F4F6;
--chat-user-text: #FFFFFF;
--chat-ai-text: #111827;
--chat-timestamp: #6B7280;
--chat-divider: #E5E7EB;
/* Border Colors */
--border-primary: #E5E7EB;
--border-secondary: #D1D5DB;
--border-focus: #3B82F6;
/* Shadows (lighter for light mode) */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.2);
}
/**
* EN: Base styles
* VI: Styles cơ bản
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: var(--text-base);
line-height: 1.5;
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
}
/**
* EN: Smooth transitions for theme switching
* VI: Chuyển đổi mượt mà cho việc chuyển theme
*/
*,
*::before,
*::after {
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out),
border-color var(--duration-normal) var(--ease-in-out);
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import './globals.css';
import { ThemeProvider } from '../contexts/theme-context';
export const metadata: Metadata = {
title: 'GoodGo Platform',
@@ -12,8 +13,10 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
/**
* EN: Analytics card variant types
* VI: Các loại biến thể analytics card
*/
export type AnalyticsCardVariant = 'metric' | 'chart' | 'progress' | 'comparison';
/**
* EN: Trend direction
* VI: Hướng trend
*/
export type TrendDirection = 'up' | 'down' | 'neutral';
/**
* EN: AnalyticsCard component props
* VI: Props của component AnalyticsCard
*/
export interface AnalyticsCardProps {
/**
* EN: Card title / VI: Tiêu đề card
*/
title: string;
/**
* EN: Main value to display / VI: Giá trị chính để hiển thị
*/
value: string | number;
/**
* EN: Change percentage (e.g., "+12.5%") / VI: Phần trăm thay đổi (VD: "+12.5%")
*/
change?: string;
/**
* EN: Trend direction / VI: Hướng trend
*/
trend?: TrendDirection;
/**
* EN: Icon component / VI: Component icon
*/
icon?: LucideIcon;
/**
* EN: Card variant / VI: Biến thể card
*/
variant?: AnalyticsCardVariant;
/**
* EN: Chart data (for chart variant) / VI: Dữ liệu chart (cho variant chart)
*/
chartData?: Array<{ label: string; value: number }>;
/**
* EN: Progress percentage (0-100) for progress variant / VI: Phần trăm tiến độ (0-100) cho variant progress
*/
progress?: number;
/**
* EN: Comparison values for comparison variant / VI: Giá trị so sánh cho variant comparison
*/
comparisons?: Array<{ label: string; value: string | number }>;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: AnalyticsCard component - Displays analytics metrics with various visualization options
* VI: Component AnalyticsCard - Hiển thị các metric analytics với các tùy chọn visualization
*
* Variants:
* - metric: Single value with trend
* - chart: Value with mini chart
* - progress: Value with progress bar
* - comparison: Multiple metrics comparison
*
* Biến thể:
* - metric: Giá trị đơn với trend
* - chart: Giá trị với mini chart
* - progress: Giá trị với thanh tiến độ
* - comparison: So sánh nhiều metrics
*/
export function AnalyticsCard({
title,
value,
change,
trend = 'neutral',
icon: Icon,
variant = 'metric',
chartData,
progress,
comparisons,
className,
}: AnalyticsCardProps) {
// EN: Get trend color / VI: Lấy màu trend
const getTrendColor = () => {
switch (trend) {
case 'up':
return 'text-accent-success';
case 'down':
return 'text-accent-error';
default:
return 'text-text-tertiary';
}
};
// EN: Get trend icon / VI: Lấy icon trend
const getTrendIcon = () => {
switch (trend) {
case 'up':
return (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
);
case 'down':
return (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"
/>
</svg>
);
default:
return null;
}
};
return (
<Card className={cn('hover', className)}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-text-secondary">
{title}
</CardTitle>
{Icon && (
<Icon className="h-4 w-4 text-text-tertiary" aria-hidden="true" />
)}
</CardHeader>
<CardContent>
{/* EN: Main value / VI: Giá trị chính */}
<div className="text-2xl font-bold text-text-primary mb-1">
{typeof value === 'number' ? value.toLocaleString() : value}
</div>
{/* EN: Change indicator / VI: Chỉ báo thay đổi */}
{change && (
<div className={cn('text-xs flex items-center gap-1', getTrendColor())}>
{getTrendIcon()}
<span>{change}</span>
</div>
)}
{/* EN: Chart variant - Mini sparkline / VI: Variant chart - Mini sparkline */}
{variant === 'chart' && chartData && chartData.length > 0 && (
<div className="mt-4 h-[60px] flex items-end gap-1">
{chartData.map((point, index) => {
const maxValue = Math.max(...chartData.map((p) => p.value));
const height = (point.value / maxValue) * 100;
return (
<div
key={index}
className="flex-1 bg-accent-primary rounded-t"
style={{ height: `${height}%` }}
aria-hidden="true"
/>
);
})}
</div>
)}
{/* EN: Progress variant - Progress bar / VI: Variant progress - Thanh tiến độ */}
{variant === 'progress' && progress !== undefined && (
<div className="mt-4">
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-accent-primary transition-all duration-[250ms]"
style={{ width: `${Math.min(progress, 100)}%` }}
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${title}: ${progress}%`}
/>
</div>
<p className="text-xs text-text-tertiary mt-1">{progress}%</p>
</div>
)}
{/* EN: Comparison variant - Multiple metrics / VI: Variant comparison - Nhiều metrics */}
{variant === 'comparison' && comparisons && comparisons.length > 0 && (
<div className="mt-4 space-y-2">
{comparisons.map((comp, index) => (
<div
key={index}
className="flex items-center justify-between text-sm"
>
<span className="text-text-secondary">{comp.label}</span>
<span className="font-medium text-text-primary">
{typeof comp.value === 'number'
? comp.value.toLocaleString()
: comp.value}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import * as React from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
/**
* EN: Revenue data point
* VI: Điểm dữ liệu doanh thu
*/
export interface RevenueDataPoint {
/**
* EN: Date label / VI: Nhãn ngày
*/
date: string;
/**
* EN: Revenue amount / VI: Số tiền doanh thu
*/
revenue: number;
/**
* EN: Optional comparison value / VI: Giá trị so sánh tùy chọn
*/
previousRevenue?: number;
}
/**
* EN: RevenueChart component props
* VI: Props của component RevenueChart
*/
export interface RevenueChartProps {
/**
* EN: Chart data / VI: Dữ liệu chart
*/
data: RevenueDataPoint[];
/**
* EN: Chart title / VI: Tiêu đề chart
*/
title?: string;
/**
* EN: Chart description / VI: Mô tả chart
*/
description?: string;
/**
* EN: Currency symbol / VI: Ký hiệu tiền tệ
*/
currency?: string;
/**
* EN: Show legend / VI: Hiển thị legend
*/
showLegend?: boolean;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: RevenueChart component - Area chart showing revenue over time
* VI: Component RevenueChart - Area chart hiển thị doanh thu theo thời gian
*
* Features:
* - Area chart with Recharts
* - Responsive container
* - Tooltip on hover with currency formatting
* - Legend support
* - Dark mode optimized colors
* - Gradient fill
*
* Tính năng:
* - Area chart với Recharts
* - Container responsive
* - Tooltip khi hover với định dạng tiền tệ
* - Hỗ trợ legend
* - Màu sắc tối ưu cho dark mode
* - Gradient fill
*/
export function RevenueChart({
data,
title = 'Revenue / Doanh thu',
description = 'Revenue over time / Doanh thu theo thời gian',
currency = '$',
showLegend = true,
className,
}: RevenueChartProps) {
// EN: Format currency value / VI: Format giá trị tiền tệ
const formatCurrency = (value: number) => {
return `${currency}${value.toLocaleString()}`;
};
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--accent-primary)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--accent-primary)" stopOpacity={0} />
</linearGradient>
{data.some((d) => d.previousRevenue !== undefined) && (
<linearGradient id="previousRevenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--accent-secondary)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--accent-secondary)" stopOpacity={0} />
</linearGradient>
)}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
<XAxis
dataKey="date"
stroke="var(--text-tertiary)"
style={{ fontSize: '12px' }}
/>
<YAxis
stroke="var(--text-tertiary)"
style={{ fontSize: '12px' }}
tickFormatter={formatCurrency}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--bg-elevated)',
border: '1px solid var(--border-primary)',
borderRadius: '8px',
color: 'var(--text-primary)',
}}
formatter={(value: number) => formatCurrency(value)}
/>
{showLegend && (
<Legend
wrapperStyle={{ color: 'var(--text-secondary)' }}
/>
)}
<Area
type="monotone"
dataKey="revenue"
stroke="var(--accent-primary)"
strokeWidth={2}
fill="url(#revenueGradient)"
name="Revenue / Doanh thu"
/>
{data.some((d) => d.previousRevenue !== undefined) && (
<Area
type="monotone"
dataKey="previousRevenue"
stroke="var(--accent-secondary)"
strokeWidth={2}
fill="url(#previousRevenueGradient)"
name="Previous Period / Kỳ trước"
/>
)}
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import * as React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
/**
* EN: User growth data point
* VI: Điểm dữ liệu tăng trưởng người dùng
*/
export interface UserGrowthDataPoint {
/**
* EN: Date label / VI: Nhãn ngày
*/
date: string;
/**
* EN: Number of users / VI: Số lượng người dùng
*/
users: number;
/**
* EN: Number of new users / VI: Số người dùng mới
*/
newUsers?: number;
}
/**
* EN: UserGrowthChart component props
* VI: Props của component UserGrowthChart
*/
export interface UserGrowthChartProps {
/**
* EN: Chart data / VI: Dữ liệu chart
*/
data: UserGrowthDataPoint[];
/**
* EN: Chart title / VI: Tiêu đề chart
*/
title?: string;
/**
* EN: Chart description / VI: Mô tả chart
*/
description?: string;
/**
* EN: Show legend / VI: Hiển thị legend
*/
showLegend?: boolean;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: UserGrowthChart component - Line chart showing user growth over time
* VI: Component UserGrowthChart - Line chart hiển thị tăng trưởng người dùng theo thời gian
*
* Features:
* - Line chart with Recharts
* - Responsive container
* - Tooltip on hover
* - Legend support
* - Dark mode optimized colors
*
* Tính năng:
* - Line chart với Recharts
* - Container responsive
* - Tooltip khi hover
* - Hỗ trợ legend
* - Màu sắc tối ưu cho dark mode
*/
export function UserGrowthChart({
data,
title = 'User Growth / Tăng trưởng người dùng',
description = 'User growth over time / Tăng trưởng người dùng theo thời gian',
showLegend = true,
className,
}: UserGrowthChartProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
<XAxis
dataKey="date"
stroke="var(--text-tertiary)"
style={{ fontSize: '12px' }}
/>
<YAxis
stroke="var(--text-tertiary)"
style={{ fontSize: '12px' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--bg-elevated)',
border: '1px solid var(--border-primary)',
borderRadius: '8px',
color: 'var(--text-primary)',
}}
/>
{showLegend && (
<Legend
wrapperStyle={{ color: 'var(--text-secondary)' }}
/>
)}
<Line
type="monotone"
dataKey="users"
stroke="var(--accent-primary)"
strokeWidth={2}
dot={{ fill: 'var(--accent-primary)', r: 4 }}
name="Total Users / Tổng người dùng"
/>
{data.some((d) => d.newUsers !== undefined) && (
<Line
type="monotone"
dataKey="newUsers"
stroke="var(--accent-success)"
strokeWidth={2}
dot={{ fill: 'var(--accent-success)', r: 4 }}
name="New Users / Người dùng mới"
/>
)}
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,491 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ArrowUpDown,
Download,
Trash2,
CheckSquare,
Square,
} from 'lucide-react';
/**
* EN: Column definition for DataTable
* VI: Định nghĩa cột cho DataTable
*/
export interface DataTableColumn<T> {
/**
* EN: Column key / VI: Key của cột
*/
key: keyof T | string;
/**
* EN: Column header label / VI: Nhãn header cột
*/
header: string;
/**
* EN: Custom cell renderer / VI: Custom cell renderer
*/
cell?: (row: T) => React.ReactNode;
/**
* EN: Enable sorting / VI: Bật sắp xếp
*/
sortable?: boolean;
/**
* EN: Column width / VI: Chiều rộng cột
*/
width?: string;
}
/**
* EN: Sort direction
* VI: Hướng sắp xếp
*/
export type SortDirection = 'asc' | 'desc' | null;
/**
* EN: DataTable component props
* VI: Props của component DataTable
*/
export interface DataTableProps<T> {
/**
* EN: Table data / VI: Dữ liệu bảng
*/
data: T[];
/**
* EN: Column definitions / VI: Định nghĩa cột
*/
columns: DataTableColumn<T>[];
/**
* EN: Current page number (1-indexed) / VI: Số trang hiện tại (bắt đầu từ 1)
*/
currentPage?: number;
/**
* EN: Number of items per page / VI: Số item mỗi trang
*/
itemsPerPage?: number;
/**
* EN: Total number of items (for server-side pagination) / VI: Tổng số item (cho pagination phía server)
*/
totalItems?: number;
/**
* EN: Callback when page changes / VI: Callback khi trang thay đổi
*/
onPageChange?: (page: number) => void;
/**
* EN: Enable row selection / VI: Bật chọn hàng
*/
selectable?: boolean;
/**
* EN: Selected row IDs / VI: IDs hàng được chọn
*/
selectedRows?: string[];
/**
* EN: Callback when selection changes / VI: Callback khi selection thay đổi
*/
onSelectionChange?: (selectedIds: string[]) => void;
/**
* EN: Get row ID function / VI: Hàm lấy ID hàng
*/
getRowId?: (row: T) => string;
/**
* EN: Enable bulk actions / VI: Bật bulk actions
*/
bulkActions?: Array<{
label: string;
action: (selectedIds: string[]) => void;
variant?: 'primary' | 'secondary' | 'danger';
}>;
/**
* EN: Enable export / VI: Bật export
*/
exportable?: boolean;
/**
* EN: Callback for export / VI: Callback cho export
*/
onExport?: () => void;
/**
* EN: Loading state / VI: Trạng thái loading
*/
loading?: boolean;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: DataTable component - Advanced table with sorting, filtering, pagination, and bulk actions
* VI: Component DataTable - Bảng nâng cao với sắp xếp, lọc, pagination và bulk actions
*
* Features:
* - Column sorting
* - Global search
* - Column filters
* - Pagination
* - Row selection
* - Bulk actions
* - Export to CSV/Excel
* - Column visibility toggle
*
* Tính năng:
* - Sắp xếp cột
* - Tìm kiếm toàn cục
* - Lọc cột
* - Pagination
* - Chọn hàng
* - Bulk actions
* - Export sang CSV/Excel
* - Toggle hiển thị cột
*/
export function DataTable<T extends Record<string, any>>({
data,
columns,
currentPage = 1,
itemsPerPage = 10,
totalItems,
onPageChange,
selectable = false,
selectedRows = [],
onSelectionChange,
getRowId = (row) => row.id || String(row),
bulkActions = [],
exportable = false,
onExport,
loading = false,
className,
}: DataTableProps<T>) {
const [sortColumn, setSortColumn] = React.useState<keyof T | string | null>(null);
const [sortDirection, setSortDirection] = React.useState<SortDirection>(null);
const [searchQuery, setSearchQuery] = React.useState('');
// EN: Calculate pagination / VI: Tính toán pagination
const totalPages = totalItems
? Math.ceil(totalItems / itemsPerPage)
: Math.ceil(data.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
// EN: Handle sorting / VI: Xử lý sắp xếp
const handleSort = (columnKey: keyof T | string) => {
if (sortColumn === columnKey) {
if (sortDirection === 'asc') {
setSortDirection('desc');
} else if (sortDirection === 'desc') {
setSortColumn(null);
setSortDirection(null);
}
} else {
setSortColumn(columnKey);
setSortDirection('asc');
}
};
// EN: Handle row selection / VI: Xử lý chọn hàng
const handleRowSelect = (rowId: string) => {
if (!onSelectionChange) return;
const newSelection = selectedRows.includes(rowId)
? selectedRows.filter((id) => id !== rowId)
: [...selectedRows, rowId];
onSelectionChange(newSelection);
};
// EN: Handle select all / VI: Xử lý chọn tất cả
const handleSelectAll = () => {
if (!onSelectionChange) return;
const allIds = data.map(getRowId);
const allSelected = allIds.every((id) => selectedRows.includes(id));
onSelectionChange(allSelected ? [] : allIds);
};
// EN: Filter and sort data / VI: Lọc và sắp xếp dữ liệu
const processedData = React.useMemo(() => {
let filtered = data;
// EN: Apply search filter / VI: Áp dụng bộ lọc tìm kiếm
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((row) =>
columns.some((col) => {
const value = row[col.key];
return value && String(value).toLowerCase().includes(query);
})
);
}
// EN: Apply sorting / VI: Áp dụng sắp xếp
if (sortColumn && sortDirection) {
filtered = [...filtered].sort((a, b) => {
const aValue = a[sortColumn];
const bValue = b[sortColumn];
if (aValue === bValue) return 0;
const comparison = aValue < bValue ? -1 : 1;
return sortDirection === 'asc' ? comparison : -comparison;
});
}
// EN: Apply pagination / VI: Áp dụng pagination
return totalItems ? filtered : filtered.slice(startIndex, endIndex);
}, [data, searchQuery, sortColumn, sortDirection, startIndex, endIndex, totalItems, columns]);
// EN: Handle page change / VI: Xử lý thay đổi trang
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages && onPageChange) {
onPageChange(page);
}
};
const allSelected = data.length > 0 && data.every((row) => selectedRows.includes(getRowId(row)));
const someSelected = selectedRows.length > 0 && !allSelected;
return (
<div className={cn('space-y-4', className)}>
{/* EN: Toolbar with search and actions / VI: Toolbar với tìm kiếm và actions */}
<div className="flex items-center justify-between gap-4">
<Input
type="search"
placeholder="Search... / Tìm kiếm..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-sm"
/>
<div className="flex items-center gap-2">
{exportable && (
<Button variant="secondary" size="sm" onClick={onExport}>
<Download className="h-4 w-4 mr-2" />
Export / Xuất
</Button>
)}
</div>
</div>
{/* EN: Bulk actions bar / VI: Thanh bulk actions */}
{selectable && selectedRows.length > 0 && (
<div className="flex items-center justify-between p-4 bg-bg-tertiary rounded-lg border border-border-primary">
<span className="text-sm text-text-secondary">
{selectedRows.length} selected / {selectedRows.length} đã chọn
</span>
<div className="flex items-center gap-2">
{bulkActions.map((action, index) => (
<Button
key={index}
variant={action.variant || 'secondary'}
size="sm"
onClick={() => action.action(selectedRows)}
>
{action.label}
</Button>
))}
</div>
</div>
)}
{/* EN: Table / VI: Bảng */}
<div className="bg-bg-secondary rounded-lg border border-border-primary overflow-hidden">
{/* EN: Mobile: Horizontal scroll / VI: Mobile: Scroll ngang */}
<div className="overflow-x-auto -mx-4 sm:mx-0">
<div className="inline-block min-w-full align-middle sm:min-w-0">
{loading ? (
<div className="p-8 text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" />
<p className="mt-4 text-sm text-text-tertiary">
Loading... / Đang tải...
</p>
</div>
) : processedData.length === 0 ? (
<div className="p-8 text-center">
<p className="text-sm text-text-tertiary">
No data found / Không tìm thấy dữ liệu
</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border-primary">
{selectable && (
<th className="px-6 py-3 text-left w-12">
<button
onClick={handleSelectAll}
className="p-1 rounded hover:bg-bg-tertiary transition-colors"
aria-label="Select all / Chọn tất cả"
>
{allSelected ? (
<CheckSquare className="h-4 w-4 text-accent-primary" />
) : someSelected ? (
<div className="h-4 w-4 border-2 border-accent-primary rounded bg-accent-primary/20" />
) : (
<Square className="h-4 w-4 text-text-tertiary" />
)}
</button>
</th>
)}
{columns.map((column) => (
<th
key={String(column.key)}
className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider"
style={{ width: column.width }}
>
<div className="flex items-center gap-2">
<span>{column.header}</span>
{column.sortable && (
<button
onClick={() => handleSort(column.key)}
className="p-1 rounded hover:bg-bg-tertiary transition-colors"
aria-label={`Sort by ${column.header} / Sắp xếp theo ${column.header}`}
>
<ArrowUpDown
className={cn(
'h-3 w-3',
sortColumn === column.key
? 'text-accent-primary'
: 'text-text-tertiary'
)}
/>
</button>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{processedData.map((row) => {
const rowId = getRowId(row);
const isSelected = selectedRows.includes(rowId);
return (
<tr
key={rowId}
className={cn(
'hover:bg-bg-tertiary transition-colors duration-[150ms]',
isSelected && 'bg-bg-tertiary'
)}
>
{selectable && (
<td className="px-6 py-4">
<button
onClick={() => handleRowSelect(rowId)}
className="p-1 rounded hover:bg-bg-elevated transition-colors"
aria-label={`Select row ${rowId} / Chọn hàng ${rowId}`}
>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-accent-primary" />
) : (
<Square className="h-4 w-4 text-text-tertiary" />
)}
</button>
</td>
)}
{columns.map((column) => (
<td key={String(column.key)} className="px-6 py-4">
{column.cell
? column.cell(row)
: String(row[column.key] || '')}
</td>
))}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
{/* EN: Pagination / VI: Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-border-primary flex items-center justify-between">
<div className="text-sm text-text-tertiary">
{totalItems ? (
<>
Showing / Hiển thị{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
to / đến{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, totalItems)}
</span>{' '}
of / trong{' '}
<span className="font-medium text-text-secondary">{totalItems}</span>{' '}
results / kết quả
</>
) : (
<>
Showing / Hiển thị{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
to / đến{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, data.length)}
</span>{' '}
of / trong{' '}
<span className="font-medium text-text-secondary">{data.length}</span>{' '}
results / kết quả
</>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
aria-label="First page / Trang đầu"
className="min-w-[44px] min-h-[44px]"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
aria-label="Previous page / Trang trước"
className="min-w-[44px] min-h-[44px]"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-text-secondary px-3 hidden sm:inline">
Page / Trang {currentPage} of / trong {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
aria-label="Next page / Trang sau"
className="min-w-[44px] min-h-[44px]"
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages || loading}
aria-label="Last page / Trang cuối"
className="min-w-[44px] min-h-[44px]"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,551 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* EN: Activity status type
* VI: Loại trạng thái hoạt động
*/
export type ActivityStatus = 'success' | 'warning' | 'error' | 'info' | 'pending';
/**
* EN: Activity action type
* VI: Loại hành động hoạt động
*/
export type ActivityAction =
| 'user_created'
| 'user_updated'
| 'user_deleted'
| 'user_login'
| 'user_logout'
| 'message_sent'
| 'message_deleted'
| 'settings_updated'
| 'system_backup'
| 'system_restore';
/**
* EN: Recent activity item interface
* VI: Interface cho item hoạt động gần đây
*/
export interface RecentActivity {
/**
* EN: Unique activity identifier / VI: Mã định danh duy nhất cho hoạt động
*/
id: string;
/**
* EN: User who performed the action / VI: Người dùng thực hiện hành động
*/
user: {
/**
* EN: User ID / VI: ID người dùng
*/
id: string;
/**
* EN: User name / VI: Tên người dùng
*/
name: string;
/**
* EN: User email / VI: Email người dùng
*/
email: string;
/**
* EN: User avatar URL (optional) / VI: URL avatar người dùng (tùy chọn)
*/
avatarUrl?: string;
};
/**
* EN: Action performed / VI: Hành động được thực hiện
*/
action: ActivityAction;
/**
* EN: Action description / VI: Mô tả hành động
*/
description: string;
/**
* EN: Activity status / VI: Trạng thái hoạt động
*/
status: ActivityStatus;
/**
* EN: Timestamp when activity occurred / VI: Timestamp khi hoạt động xảy ra
*/
timestamp: Date | string;
/**
* EN: Additional metadata (optional) / VI: Metadata bổ sung (tùy chọn)
*/
metadata?: Record<string, unknown>;
}
/**
* EN: RecentActivityTable component props
* VI: Props của component RecentActivityTable
*/
export interface RecentActivityTableProps {
/**
* EN: Array of recent activities / VI: Mảng các hoạt động gần đây
*/
activities: RecentActivity[];
/**
* EN: Current page number (1-indexed) / VI: Số trang hiện tại (bắt đầu từ 1)
*/
currentPage?: number;
/**
* EN: Number of items per page / VI: Số item mỗi trang
*/
itemsPerPage?: number;
/**
* EN: Total number of items (for server-side pagination) / VI: Tổng số item (cho pagination phía server)
*/
totalItems?: number;
/**
* EN: Callback when page changes / VI: Callback khi trang thay đổi
*/
onPageChange?: (page: number) => void;
/**
* EN: Callback for quick action click / VI: Callback khi click quick action
*/
onQuickAction?: (activityId: string, action: string) => void;
/**
* EN: Loading state / VI: Trạng thái loading
*/
loading?: boolean;
/**
* EN: Additional CSS classes / VI: Các class CSS bổ sung
*/
className?: string;
}
/**
* EN: Get user initials for avatar fallback
* VI: Lấy initials của user cho avatar fallback
*/
function getUserInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
/**
* EN: Format timestamp to relative time
* VI: Format timestamp thành thời gian tương đối
*/
function formatRelativeTime(date: Date | string): string {
const now = new Date();
const activityDate = typeof date === 'string' ? new Date(date) : date;
const diffMs = now.getTime() - activityDate.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now / Vừa xong';
if (diffMins < 60) return `${diffMins}m ago / ${diffMins} phút trước`;
if (diffHours < 24) return `${diffHours}h ago / ${diffHours} giờ trước`;
if (diffDays < 7) return `${diffDays}d ago / ${diffDays} ngày trước`;
return activityDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: diffDays >= 365 ? 'numeric' : undefined,
});
}
/**
* EN: Get status badge styles
* VI: Lấy styles cho status badge
*/
function getStatusBadgeStyles(status: ActivityStatus): string {
const styles = {
success:
'bg-accent-success/20 text-accent-success border-accent-success/30',
warning:
'bg-accent-warning/20 text-accent-warning border-accent-warning/30',
error:
'bg-accent-error/20 text-accent-error border-accent-error/30',
info: 'bg-accent-info/20 text-accent-info border-accent-info/30',
pending:
'bg-text-tertiary/20 text-text-tertiary border-text-tertiary/30',
};
return styles[status];
}
/**
* EN: Get status label
* VI: Lấy label cho status
*/
function getStatusLabel(status: ActivityStatus): string {
const labels = {
success: 'Success / Thành công',
warning: 'Warning / Cảnh báo',
error: 'Error / Lỗi',
info: 'Info / Thông tin',
pending: 'Pending / Đang chờ',
};
return labels[status];
}
/**
* EN: Get action label
* VI: Lấy label cho action
*/
function getActionLabel(action: ActivityAction): string {
const labels: Record<ActivityAction, string> = {
user_created: 'User Created / Tạo người dùng',
user_updated: 'User Updated / Cập nhật người dùng',
user_deleted: 'User Deleted / Xóa người dùng',
user_login: 'User Login / Đăng nhập',
user_logout: 'User Logout / Đăng xuất',
message_sent: 'Message Sent / Gửi tin nhắn',
message_deleted: 'Message Deleted / Xóa tin nhắn',
settings_updated: 'Settings Updated / Cập nhật cài đặt',
system_backup: 'System Backup / Sao lưu hệ thống',
system_restore: 'System Restore / Khôi phục hệ thống',
};
return labels[action] || action;
}
/**
* EN: RecentActivityTable component - Displays recent activities with pagination
* VI: Component RecentActivityTable - Hiển thị các hoạt động gần đây với pagination
*
* Features:
* - User avatar + name
* - Action performed
* - Timestamp (relative)
* - Status badge
* - Quick actions
* - Pagination
*
* Tính năng:
* - Avatar + tên người dùng
* - Hành động được thực hiện
* - Timestamp (tương đối)
* - Badge trạng thái
* - Quick actions
* - Pagination
*
* @example
* ```tsx
* <RecentActivityTable
* activities={activities}
* currentPage={1}
* itemsPerPage={10}
* onPageChange={(page) => setPage(page)}
* onQuickAction={(id, action) => handleAction(id, action)}
* />
* ```
*/
export function RecentActivityTable({
activities,
currentPage = 1,
itemsPerPage = 10,
totalItems,
onPageChange,
onQuickAction,
loading = false,
className,
}: RecentActivityTableProps) {
// EN: Calculate pagination / VI: Tính toán pagination
const totalPages = totalItems
? Math.ceil(totalItems / itemsPerPage)
: Math.ceil(activities.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedActivities = totalItems
? activities // EN: Server-side pagination / VI: Pagination phía server
: activities.slice(startIndex, endIndex); // EN: Client-side pagination / VI: Pagination phía client
// EN: Handle page change / VI: Xử lý thay đổi trang
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages && onPageChange) {
onPageChange(page);
}
};
// EN: Handle quick action / VI: Xử lý quick action
const handleQuickAction = (activityId: string, action: string) => {
if (onQuickAction) {
onQuickAction(activityId, action);
}
};
return (
<div
className={cn(
'bg-bg-secondary rounded-xl border border-border-primary overflow-hidden',
className
)}
>
{/* EN: Table Header / VI: Header bảng */}
<div className="px-6 py-4 border-b border-border-primary">
<h3 className="text-lg font-semibold text-text-primary">
Recent Activity / Hoạt đng gần đây
</h3>
<p className="text-sm text-text-tertiary mt-1">
Latest system activities / Các hoạt đng hệ thống mới nhất
</p>
</div>
{/* EN: Table Content / VI: Nội dung bảng */}
{/* EN: Mobile: Horizontal scroll / VI: Mobile: Scroll ngang */}
<div className="overflow-x-auto -mx-4 sm:mx-0">
<div className="inline-block min-w-full align-middle sm:min-w-0 px-4 sm:px-0">
{loading ? (
// EN: Loading state / VI: Trạng thái loading
<div className="p-8 text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" />
<p className="mt-4 text-sm text-text-tertiary">
Loading activities... / Đang tải hoạt đng...
</p>
</div>
) : paginatedActivities.length === 0 ? (
// EN: Empty state / VI: Trạng thái trống
<div className="p-8 text-center">
<p className="text-sm text-text-tertiary">
No activities found / Không tìm thấy hoạt đng nào
</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border-primary">
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
User / Người dùng
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
Action / Hành đng
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
Status / Trạng thái
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
Time / Thời gian
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-tertiary uppercase tracking-wider">
Actions / Hành đng
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{paginatedActivities.map((activity) => (
<tr
key={activity.id}
className="hover:bg-bg-tertiary transition-colors duration-[150ms]"
>
{/* EN: User column / VI: Cột người dùng */}
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{/* EN: Avatar / VI: Avatar */}
<div className="relative flex-shrink-0 h-10 w-10">
{activity.user.avatarUrl ? (
<img
src={activity.user.avatarUrl}
alt={`Avatar of ${activity.user.name} / Avatar của ${activity.user.name}`}
className="h-10 w-10 rounded-full object-cover"
loading="lazy"
/>
) : (
<div className="h-10 w-10 rounded-full bg-chat-ai-bubble flex items-center justify-center">
<span className="text-sm font-medium text-text-primary">
{getUserInitials(activity.user.name)}
</span>
</div>
)}
</div>
<div className="ml-3">
<div className="text-sm font-medium text-text-primary">
{activity.user.name}
</div>
<div className="text-sm text-text-tertiary">
{activity.user.email}
</div>
</div>
</div>
</td>
{/* EN: Action column / VI: Cột hành động */}
<td className="px-6 py-4">
<div className="text-sm text-text-secondary">
{getActionLabel(activity.action)}
</div>
<div className="text-xs text-text-tertiary mt-1">
{activity.description}
</div>
</td>
{/* EN: Status column / VI: Cột trạng thái */}
<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',
getStatusBadgeStyles(activity.status)
)}
>
{getStatusLabel(activity.status)}
</span>
</td>
{/* EN: Time column / VI: Cột thời gian */}
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-tertiary">
{formatRelativeTime(activity.timestamp)}
</td>
{/* EN: Quick actions column / VI: Cột quick actions */}
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleQuickAction(activity.id, 'view')}
className="text-accent-primary hover:brightness-110 transition-colors duration-[150ms] min-h-[44px] px-2 py-1"
aria-label="View details / Xem chi tiết"
>
View / Xem
</button>
<span className="text-border-primary">|</span>
<button
onClick={() => handleQuickAction(activity.id, 'copy')}
className="text-accent-primary hover:brightness-110 transition-colors duration-[150ms] min-h-[44px] px-2 py-1"
aria-label="Copy ID / Sao chép ID"
>
Copy / Sao chép
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* EN: Pagination / VI: Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-border-primary flex items-center justify-between">
<div className="text-sm text-text-tertiary">
{totalItems ? (
<>
Showing / Hiển thị{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
to / đến{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, totalItems)}
</span>{' '}
of / trong{' '}
<span className="font-medium text-text-secondary">{totalItems}</span>{' '}
results / kết quả
</>
) : (
<>
Showing / Hiển thị{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
to / đến{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, activities.length)}
</span>{' '}
of / trong{' '}
<span className="font-medium text-text-secondary">
{activities.length}
</span>{' '}
results / kết quả
</>
)}
</div>
<div className="flex items-center gap-2">
{/* EN: Previous button / VI: Nút trước */}
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-[150ms]',
'border border-border-primary',
// EN: Mobile: Minimum 44px touch target / VI: Mobile: Touch target tối thiểu 44px
'min-w-[44px] min-h-[44px]',
currentPage === 1 || loading
? 'opacity-50 cursor-not-allowed text-text-tertiary'
: 'text-text-secondary hover:bg-bg-tertiary hover:border-border-secondary hover:scale-[1.02] active:scale-[0.98]'
)}
aria-label="Previous page / Trang trước"
>
<span className="hidden sm:inline">Previous / Trước</span>
<span className="sm:hidden">Prev</span>
</button>
{/* EN: Page numbers / VI: Số trang */}
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => {
// EN: Show first page, last page, current page, and pages around current / VI: Hiển thị trang đầu, cuối, hiện tại và các trang xung quanh
if (
page === 1 ||
page === totalPages ||
(page >= currentPage - 1 && page <= currentPage + 1)
) {
return (
<button
key={page}
onClick={() => handlePageChange(page)}
disabled={loading}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-[150ms]',
'border border-border-primary',
page === currentPage
? 'bg-accent-primary text-white border-accent-primary'
: 'text-text-secondary hover:bg-bg-tertiary hover:border-border-secondary hover:scale-[1.02] active:scale-[0.98]',
loading && 'opacity-50 cursor-not-allowed'
)}
aria-label={`Page ${page} / Trang ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
);
} else if (
page === currentPage - 2 ||
page === currentPage + 2
) {
return (
<span
key={page}
className="px-2 text-sm text-text-tertiary"
>
...
</span>
);
}
return null;
}
)}
</div>
{/* EN: Next button / VI: Nút sau */}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
className={cn(
'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-[150ms]',
'border border-border-primary',
// EN: Mobile: Minimum 44px touch target / VI: Mobile: Touch target tối thiểu 44px
'min-w-[44px] min-h-[44px]',
currentPage === totalPages || loading
? 'opacity-50 cursor-not-allowed text-text-tertiary'
: 'text-text-secondary hover:bg-bg-tertiary hover:border-border-secondary hover:scale-[1.02] active:scale-[0.98]'
)}
aria-label="Next page / Trang sau"
>
<span className="hidden sm:inline">Next / Sau</span>
<span className="sm:hidden">Next</span>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,250 @@
'use client';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
/**
* EN: Email settings form validation schema
* VI: Schema validation cho form cài đặt email
*/
const emailSettingsSchema = z.object({
smtpHost: z.string().min(1, 'SMTP host is required / SMTP host là bắt buộc'),
smtpPort: z.number().min(1).max(65535),
smtpUser: z.string().min(1, 'SMTP user is required / SMTP user là bắt buộc'),
smtpPassword: z.string().min(1, 'SMTP password is required / SMTP password là bắt buộc'),
smtpFromEmail: z.string().email('Invalid email format / Định dạng email không hợp lệ'),
smtpFromName: z.string().min(1, 'From name is required / Tên người gửi là bắt buộc'),
});
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
/**
* EN: EmailSettings component - Form for email/SMTP configuration
* VI: Component EmailSettings - Form cho cấu hình email/SMTP
*
* Features:
* - SMTP configuration (host, port, user, password)
* - From email and name
* - Email templates management
* - Test email function
*
* Tính năng:
* - Cấu hình SMTP (host, port, user, password)
* - Email và tên người gửi
* - Quản lý email templates
* - Chức năng test email
*/
export function EmailSettings() {
const [isSaving, setIsSaving] = React.useState(false);
const [isTesting, setIsTesting] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
const [testResult, setTestResult] = React.useState<'success' | 'error' | null>(null);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
} = useForm<EmailSettingsFormData>({
resolver: zodResolver(emailSettingsSchema),
defaultValues: {
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpUser: '',
smtpPassword: '',
smtpFromEmail: 'noreply@goodgo.vn',
smtpFromName: 'GoodGo Platform',
},
});
// EN: Handle form submission / VI: Xử lý submit form
const onSubmit = async (data: EmailSettingsFormData) => {
setIsSaving(true);
setSaveSuccess(false);
try {
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
await new Promise((resolve) => setTimeout(resolve, 500));
reset(data);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (error) {
console.error('Failed to save email settings / Không thể lưu cài đặt email:', error);
} finally {
setIsSaving(false);
}
};
// EN: Handle test email / VI: Xử lý test email
const handleTestEmail = async () => {
setIsTesting(true);
setTestResult(null);
try {
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
await new Promise((resolve) => setTimeout(resolve, 1000));
setTestResult('success');
setTimeout(() => setTestResult(null), 3000);
} catch (error) {
setTestResult('error');
setTimeout(() => setTestResult(null), 3000);
} finally {
setIsTesting(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Email Settings / Cài đt Email</CardTitle>
<CardDescription>
Configure SMTP settings for sending emails / Cấu hình cài đt SMTP đ gửi email
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
{/* EN: SMTP Configuration / VI: Cấu hình SMTP */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
SMTP Configuration / Cấu hình SMTP
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="SMTP Host / SMTP Host"
placeholder="smtp.example.com"
{...register('smtpHost')}
errorMessage={errors.smtpHost?.message}
validationState={errors.smtpHost ? 'error' : 'default'}
/>
<Input
label="SMTP Port / SMTP Port"
type="number"
placeholder="587"
{...register('smtpPort', { valueAsNumber: true })}
errorMessage={errors.smtpPort?.message}
validationState={errors.smtpPort ? 'error' : 'default'}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="SMTP User / SMTP User"
placeholder="your-email@example.com"
{...register('smtpUser')}
errorMessage={errors.smtpUser?.message}
validationState={errors.smtpUser ? 'error' : 'default'}
/>
<Input
label="SMTP Password / SMTP Password"
type="password"
placeholder="Enter SMTP password / Nhập mật khẩu SMTP"
{...register('smtpPassword')}
errorMessage={errors.smtpPassword?.message}
validationState={errors.smtpPassword ? 'error' : 'default'}
/>
</div>
</div>
{/* EN: From Settings / VI: Cài đặt người gửi */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
From Settings / Cài đt người gửi
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="From Email / Email người gửi"
type="email"
placeholder="noreply@goodgo.vn"
{...register('smtpFromEmail')}
errorMessage={errors.smtpFromEmail?.message}
validationState={errors.smtpFromEmail ? 'error' : 'default'}
/>
<Input
label="From Name / Tên người gửi"
placeholder="GoodGo Platform"
{...register('smtpFromName')}
errorMessage={errors.smtpFromName?.message}
validationState={errors.smtpFromName ? 'error' : 'default'}
/>
</div>
</div>
{/* EN: Test email section / VI: Phần test email */}
<div className="p-4 rounded-lg bg-bg-tertiary border border-border-primary">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-text-primary">
Test Email / Test Email
</h4>
<p className="text-sm text-text-tertiary mt-1">
Send a test email to verify SMTP configuration / Gửi email test đ xác minh cấu hình SMTP
</p>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleTestEmail}
loading={isTesting}
>
Send Test Email / Gửi email test
</Button>
</div>
{testResult === 'success' && (
<p className="text-sm text-accent-success mt-2">
Test email sent successfully! / Email test đã đưc gi thành công!
</p>
)}
{testResult === 'error' && (
<p className="text-sm text-accent-error mt-2">
Failed to send test email / Không thể gửi email test
</p>
)}
</div>
{/* EN: Success message / VI: Thông báo thành công */}
{saveSuccess && (
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Settings saved successfully / Đã lưu cài đt thành công
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
Save Changes / Lưu thay đi
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
/**
* EN: General settings form validation schema
* VI: Schema validation cho form cài đặt chung
*/
const generalSettingsSchema = z.object({
siteName: z.string().min(1, 'Site name is required / Tên site là bắt buộc'),
defaultLanguage: z.enum(['en', 'vi'], {
required_error: 'Language is required / Ngôn ngữ là bắt buộc',
}),
timezone: z.string().min(1, 'Timezone is required / Múi giờ là bắt buộc'),
maintenanceMode: z.boolean(),
});
type GeneralSettingsFormData = z.infer<typeof generalSettingsSchema>;
/**
* EN: GeneralSettings component - Form for general system settings
* VI: Component GeneralSettings - Form cho cài đặt hệ thống chung
*
* Features:
* - Site name input
* - Logo upload
* - Default language selection
* - Timezone selection
* - Maintenance mode toggle
*
* Tính năng:
* - Input tên site
* - Upload logo
* - Chọn ngôn ngữ mặc định
* - Chọn múi giờ
* - Toggle chế độ bảo trì
*/
export function GeneralSettings() {
const [isSaving, setIsSaving] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
watch,
setValue,
} = useForm<GeneralSettingsFormData>({
resolver: zodResolver(generalSettingsSchema),
defaultValues: {
siteName: 'GoodGo Platform',
defaultLanguage: 'en',
timezone: 'UTC',
maintenanceMode: false,
},
});
const maintenanceMode = watch('maintenanceMode');
// EN: Handle form submission / VI: Xử lý submit form
const onSubmit = async (data: GeneralSettingsFormData) => {
setIsSaving(true);
setSaveSuccess(false);
try {
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
await new Promise((resolve) => setTimeout(resolve, 500));
reset(data);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (error) {
console.error('Failed to save settings / Không thể lưu cài đặt:', error);
} finally {
setIsSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>General Settings / Cài đt chung</CardTitle>
<CardDescription>
Configure general system settings / Cấu hình cài đt hệ thống chung
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
{/* EN: Site name / VI: Tên site */}
<Input
label="Site Name / Tên site"
placeholder="Enter site name / Nhập tên site"
{...register('siteName')}
errorMessage={errors.siteName?.message}
validationState={errors.siteName ? 'error' : 'default'}
/>
{/* EN: Logo upload / VI: Upload logo */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Site Logo / Logo site
</label>
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-lg bg-bg-tertiary border border-border-primary flex items-center justify-center">
<span className="text-xs text-text-tertiary">Logo</span>
</div>
<div>
<Button variant="secondary" size="sm" type="button">
Upload Logo / Tải logo
</Button>
<p className="text-xs text-text-tertiary mt-1">
Recommended: 200x200px, PNG or SVG / Khuyến nghị: 200x200px, PNG hoặc SVG
</p>
</div>
</div>
</div>
{/* EN: Default language / VI: Ngôn ngữ mặc định */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Default Language / Ngôn ngữ mặc đnh
</label>
<select
{...register('defaultLanguage')}
className="flex w-full rounded-md border border-border-primary bg-bg-secondary px-3 py-2 text-base text-text-primary transition-all duration-[150ms] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2"
>
<option value="en">English</option>
<option value="vi">Tiếng Việt</option>
</select>
</div>
{/* EN: Timezone / VI: Múi giờ */}
<Input
label="Timezone / Múi giờ"
placeholder="UTC"
{...register('timezone')}
errorMessage={errors.timezone?.message}
validationState={errors.timezone ? 'error' : 'default'}
helperText="Server timezone / Múi giờ server"
/>
{/* EN: Maintenance mode / VI: Chế độ bảo trì */}
<div className="flex items-center justify-between p-4 rounded-lg bg-bg-tertiary border border-border-primary">
<div className="flex-1">
<label
htmlFor="maintenance-mode"
className="text-sm font-medium text-text-primary cursor-pointer"
>
Maintenance Mode / Chế đ bảo trì
</label>
<p className="text-sm text-text-tertiary mt-1">
Enable maintenance mode to restrict access / Bật chế đ bảo trì đ hạn chế truy cập
</p>
</div>
<Switch
id="maintenance-mode"
checked={maintenanceMode}
onCheckedChange={(checked) => setValue('maintenanceMode', checked)}
/>
</div>
{/* EN: Success message / VI: Thông báo thành công */}
{saveSuccess && (
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Settings saved successfully / Đã lưu cài đt thành công
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
Save Changes / Lưu thay đi
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,293 @@
'use client';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
/**
* EN: Security settings form validation schema
* VI: Schema validation cho form cài đặt bảo mật
*/
const securitySettingsSchema = z.object({
minPasswordLength: z.number().min(8).max(128),
requireUppercase: z.boolean(),
requireLowercase: z.boolean(),
requireNumbers: z.boolean(),
requireSpecialChars: z.boolean(),
sessionTimeout: z.number().min(5).max(1440), // minutes
maxLoginAttempts: z.number().min(3).max(10),
lockoutDuration: z.number().min(5).max(60), // minutes
enableRateLimiting: z.boolean(),
rateLimitRequests: z.number().min(10).max(10000),
rateLimitWindow: z.number().min(1).max(60), // minutes
});
type SecuritySettingsFormData = z.infer<typeof securitySettingsSchema>;
/**
* EN: SecuritySettings component - Form for security settings
* VI: Component SecuritySettings - Form cho cài đặt bảo mật
*
* Features:
* - Password policy configuration
* - Session timeout
* - IP whitelist/blacklist
* - Rate limiting
* - CORS settings
*
* Tính năng:
* - Cấu hình chính sách mật khẩu
* - Timeout session
* - IP whitelist/blacklist
* - Rate limiting
* - Cài đặt CORS
*/
export function SecuritySettings() {
const [isSaving, setIsSaving] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
watch,
setValue,
} = useForm<SecuritySettingsFormData>({
resolver: zodResolver(securitySettingsSchema),
defaultValues: {
minPasswordLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: true,
sessionTimeout: 30,
maxLoginAttempts: 5,
lockoutDuration: 15,
enableRateLimiting: true,
rateLimitRequests: 100,
rateLimitWindow: 1,
},
});
// EN: Handle form submission / VI: Xử lý submit form
const onSubmit = async (data: SecuritySettingsFormData) => {
setIsSaving(true);
setSaveSuccess(false);
try {
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
await new Promise((resolve) => setTimeout(resolve, 500));
reset(data);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (error) {
console.error('Failed to save security settings / Không thể lưu cài đặt bảo mật:', error);
} finally {
setIsSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Security Settings / Cài đt bảo mật</CardTitle>
<CardDescription>
Configure security policies and restrictions / Cấu hình chính sách hạn chế bảo mật
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
{/* EN: Password Policy / VI: Chính sách mật khẩu */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
Password Policy / Chính sách mật khẩu
</h3>
<Input
label="Minimum Password Length / Độ dài mật khẩu tối thiểu"
type="number"
{...register('minPasswordLength', { valueAsNumber: true })}
errorMessage={errors.minPasswordLength?.message}
validationState={errors.minPasswordLength ? 'error' : 'default'}
helperText="Minimum 8 characters / Tối thiểu 8 ký tự"
/>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
<label htmlFor="require-uppercase" className="text-sm font-medium text-text-primary cursor-pointer">
Require Uppercase / Yêu cầu chữ hoa
</label>
<Switch
id="require-uppercase"
checked={watch('requireUppercase')}
onCheckedChange={(checked) => setValue('requireUppercase', checked)}
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
<label htmlFor="require-lowercase" className="text-sm font-medium text-text-primary cursor-pointer">
Require Lowercase / Yêu cầu chữ thường
</label>
<Switch
id="require-lowercase"
checked={watch('requireLowercase')}
onCheckedChange={(checked) => setValue('requireLowercase', checked)}
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
<label htmlFor="require-numbers" className="text-sm font-medium text-text-primary cursor-pointer">
Require Numbers / Yêu cầu số
</label>
<Switch
id="require-numbers"
checked={watch('requireNumbers')}
onCheckedChange={(checked) => setValue('requireNumbers', checked)}
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
<label htmlFor="require-special" className="text-sm font-medium text-text-primary cursor-pointer">
Require Special Characters / Yêu cầu tự đc biệt
</label>
<Switch
id="require-special"
checked={watch('requireSpecialChars')}
onCheckedChange={(checked) => setValue('requireSpecialChars', checked)}
/>
</div>
</div>
</div>
{/* EN: Session Settings / VI: Cài đặt session */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
Session Settings / Cài đt session
</h3>
<Input
label="Session Timeout (minutes) / Timeout session (phút)"
type="number"
{...register('sessionTimeout', { valueAsNumber: true })}
errorMessage={errors.sessionTimeout?.message}
validationState={errors.sessionTimeout ? 'error' : 'default'}
/>
<Input
label="Max Login Attempts / Số lần đăng nhập tối đa"
type="number"
{...register('maxLoginAttempts', { valueAsNumber: true })}
errorMessage={errors.maxLoginAttempts?.message}
validationState={errors.maxLoginAttempts ? 'error' : 'default'}
/>
<Input
label="Lockout Duration (minutes) / Thời gian khóa (phút)"
type="number"
{...register('lockoutDuration', { valueAsNumber: true })}
errorMessage={errors.lockoutDuration?.message}
validationState={errors.lockoutDuration ? 'error' : 'default'}
/>
</div>
{/* EN: Rate Limiting / VI: Rate limiting */}
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-bg-tertiary border border-border-primary">
<div className="flex-1">
<label htmlFor="enable-rate-limiting" className="text-sm font-medium text-text-primary cursor-pointer">
Enable Rate Limiting / Bật rate limiting
</label>
<p className="text-sm text-text-tertiary mt-1">
Limit requests per time window / Giới hạn requests mỗi cửa sổ thời gian
</p>
</div>
<Switch
id="enable-rate-limiting"
checked={watch('enableRateLimiting')}
onCheckedChange={(checked) => setValue('enableRateLimiting', checked)}
/>
</div>
{watch('enableRateLimiting') && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label="Max Requests / Requests tối đa"
type="number"
{...register('rateLimitRequests', { valueAsNumber: true })}
errorMessage={errors.rateLimitRequests?.message}
validationState={errors.rateLimitRequests ? 'error' : 'default'}
/>
<Input
label="Time Window (minutes) / Cửa sổ thời gian (phút)"
type="number"
{...register('rateLimitWindow', { valueAsNumber: true })}
errorMessage={errors.rateLimitWindow?.message}
validationState={errors.rateLimitWindow ? 'error' : 'default'}
/>
</div>
)}
</div>
{/* EN: IP Management placeholder / VI: Placeholder quản lý IP */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
IP Management / Quản IP
</h3>
<p className="text-sm text-text-tertiary">
IP whitelist/blacklist management will be implemented here / Quản IP whitelist/blacklist sẽ đưc implement đây
</p>
</div>
{/* EN: CORS Settings placeholder / VI: Placeholder cài đặt CORS */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
CORS Settings / Cài đt CORS
</h3>
<p className="text-sm text-text-tertiary">
CORS configuration will be implemented here / Cấu hình CORS sẽ đưc implement đây
</p>
</div>
{/* EN: Success message / VI: Thông báo thành công */}
{saveSuccess && (
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Settings saved successfully / Đã lưu cài đt thành công
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
Save Changes / Lưu thay đi
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,343 @@
'use client';
import * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Edit, Trash2, Ban, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* EN: User interface
* VI: Interface cho User
*/
export interface User {
id: string;
email: string;
firstName?: string;
lastName?: string;
role: string;
status: 'active' | 'inactive' | 'banned';
avatarUrl?: string;
createdAt: string;
lastLoginAt?: string;
}
/**
* EN: Activity log entry
* VI: Entry log hoạt động
*/
export interface ActivityLog {
id: string;
action: string;
description: string;
timestamp: string;
}
/**
* EN: Message history entry
* VI: Entry lịch sử tin nhắn
*/
export interface MessageHistory {
id: string;
content: string;
conversationId: string;
timestamp: string;
}
/**
* EN: UserDetailsModal component props
* VI: Props của component UserDetailsModal
*/
export interface UserDetailsModalProps {
/**
* EN: User data / VI: Dữ liệu người dùng
*/
user: User | null;
/**
* EN: Modal open state / VI: Trạng thái mở modal
*/
open: boolean;
/**
* EN: Callback when modal closes / VI: Callback khi modal đóng
*/
onOpenChange: (open: boolean) => void;
/**
* EN: Activity logs / VI: Log hoạt động
*/
activityLogs?: ActivityLog[];
/**
* EN: Message history / VI: Lịch sử tin nhắn
*/
messageHistory?: MessageHistory[];
/**
* EN: Callback when edit is clicked / VI: Callback khi edit được click
*/
onEdit?: (user: User) => void;
/**
* EN: Callback when delete is clicked / VI: Callback khi delete được click
*/
onDelete?: (userId: string) => void;
/**
* EN: Callback when deactivate is clicked / VI: Callback khi deactivate được click
*/
onDeactivate?: (userId: string) => void;
}
/**
* EN: UserDetailsModal component - Modal showing user details, activity timeline, and message history
* VI: Component UserDetailsModal - Modal hiển thị chi tiết người dùng, timeline hoạt động và lịch sử tin nhắn
*
* Features:
* - User profile information
* - Activity timeline
* - Message history
* - Edit, Delete, Deactivate actions
*
* Tính năng:
* - Thông tin profile người dùng
* - Timeline hoạt động
* - Lịch sử tin nhắn
* - Các hành động Edit, Delete, Deactivate
*/
export function UserDetailsModal({
user,
open,
onOpenChange,
activityLogs = [],
messageHistory = [],
onEdit,
onDelete,
onDeactivate,
}: UserDetailsModalProps) {
if (!user) return null;
// EN: Get user initials / VI: Lấy initials của user
const getUserInitials = () => {
if (user.firstName && user.lastName) {
return `${user.firstName.charAt(0)}${user.lastName.charAt(0)}`.toUpperCase();
}
return user.email.charAt(0).toUpperCase();
};
// EN: Format timestamp / VI: Format timestamp
const formatTimestamp = (date: string) => {
return new Date(date).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// EN: Get status badge color / VI: Lấy màu badge trạng thái
const getStatusColor = () => {
switch (user.status) {
case 'active':
return 'bg-accent-success/20 text-accent-success border-accent-success/30';
case 'inactive':
return 'bg-text-tertiary/20 text-text-tertiary border-text-tertiary/30';
case 'banned':
return 'bg-accent-error/20 text-accent-error border-accent-error/30';
default:
return 'bg-text-tertiary/20 text-text-tertiary border-text-tertiary/30';
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>User Details / Chi tiết người dùng</DialogTitle>
<DialogDescription>
View and manage user information / Xem quản thông tin người dùng
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="profile" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="profile">Profile / Hồ </TabsTrigger>
<TabsTrigger value="activity">Activity / Hoạt đng</TabsTrigger>
<TabsTrigger value="messages">Messages / Tin nhắn</TabsTrigger>
</TabsList>
{/* EN: Profile tab / VI: Tab Profile */}
<TabsContent value="profile" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<Avatar size="lg" className="h-20 w-20">
{user.avatarUrl && (
<AvatarImage src={user.avatarUrl} alt={user.email} />
)}
<AvatarFallback>{getUserInitials()}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="text-xl font-semibold text-text-primary">
{user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.email}
</h3>
<p className="text-sm text-text-tertiary mt-1">{user.email}</p>
<div className="mt-2">
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
getStatusColor()
)}
>
{user.status === 'active' && (
<CheckCircle2 className="h-3 w-3 mr-1" />
)}
{user.status === 'banned' && (
<Ban className="h-3 w-3 mr-1" />
)}
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-text-tertiary mb-1">Role / Vai trò</p>
<p className="text-sm font-medium text-text-primary">{user.role}</p>
</div>
<div>
<p className="text-xs text-text-tertiary mb-1">
Created / Tạo
</p>
<p className="text-sm font-medium text-text-primary">
{formatTimestamp(user.createdAt)}
</p>
</div>
{user.lastLoginAt && (
<div>
<p className="text-xs text-text-tertiary mb-1">
Last Login / Lần đăng nhập cuối
</p>
<p className="text-sm font-medium text-text-primary">
{formatTimestamp(user.lastLoginAt)}
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* EN: Action buttons / VI: Nút hành động */}
<div className="flex items-center justify-end gap-2">
{onEdit && (
<Button variant="secondary" size="sm" onClick={() => onEdit(user)}>
<Edit className="h-4 w-4 mr-2" />
Edit / Chỉnh sửa
</Button>
)}
{onDeactivate && (
<Button
variant="secondary"
size="sm"
onClick={() => onDeactivate(user.id)}
>
<Ban className="h-4 w-4 mr-2" />
{user.status === 'active' ? 'Deactivate / Vô hiệu hóa' : 'Activate / Kích hoạt'}
</Button>
)}
{onDelete && (
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm('Are you sure you want to delete this user? / Bạn có chắc chắn muốn xóa người dùng này?')) {
onDelete(user.id);
}
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete / Xóa
</Button>
)}
</div>
</TabsContent>
{/* EN: Activity tab / VI: Tab Activity */}
<TabsContent value="activity" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Activity Timeline / Timeline hoạt đng</CardTitle>
</CardHeader>
<CardContent>
{activityLogs.length === 0 ? (
<p className="text-sm text-text-tertiary text-center py-8">
No activity logs / Không log hoạt đng
</p>
) : (
<div className="space-y-4">
{activityLogs.map((log) => (
<div
key={log.id}
className="flex items-start gap-4 pb-4 border-b border-border-primary last:border-0"
>
<div className="flex-1">
<p className="text-sm font-medium text-text-primary">
{log.action}
</p>
<p className="text-sm text-text-secondary mt-1">
{log.description}
</p>
<p className="text-xs text-text-tertiary mt-1">
{formatTimestamp(log.timestamp)}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* EN: Messages tab / VI: Tab Messages */}
<TabsContent value="messages" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Message History / Lịch sử tin nhắn</CardTitle>
</CardHeader>
<CardContent>
{messageHistory.length === 0 ? (
<p className="text-sm text-text-tertiary text-center py-8">
No messages / Không tin nhắn
</p>
) : (
<div className="space-y-4">
{messageHistory.map((message) => (
<div
key={message.id}
className="p-4 rounded-lg bg-bg-tertiary border border-border-primary"
>
<p className="text-sm text-text-primary">{message.content}</p>
<p className="text-xs text-text-tertiary mt-2">
{formatTimestamp(message.timestamp)}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { useTheme } from '../contexts/theme-context';
/**
* EN: Theme toggle button component
* VI: Component nút chuyển đổi theme
*/
export function ThemeToggle() {
const { resolvedTheme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
aria-label={resolvedTheme === 'dark' ? 'Switch to light mode / Chuyển sang chế độ sáng' : 'Switch to dark mode / Chuyển sang chế độ tối'}
type="button"
>
{resolvedTheme === 'dark' ? (
// EN: Sun icon for light mode / VI: Icon mặt trời cho chế độ sáng
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
) : (
// EN: Moon icon for dark mode / VI: Icon mặt trăng cho chế độ tối
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
)}
</button>
);
}

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/avatar.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/button.stories.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/button.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/card.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/dialog.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/dropdown-menu.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/input.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/select.tsx

View File

@@ -0,0 +1 @@
../web-client/src/components/ui/switch.tsx

View File

@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
/**
* EN: Tabs component - Tab navigation using Radix UI
* VI: Component Tabs - Điều hướng tab sử dụng Radix UI
*/
const Tabs = TabsPrimitive.Root;
/**
* EN: TabsList component - Container for tab triggers
* VI: Component TabsList - Container cho tab triggers
*/
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-lg bg-bg-tertiary p-1 text-text-secondary',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
/**
* EN: TabsTrigger component - Individual tab button
* VI: Component TabsTrigger - Nút tab riêng lẻ
*/
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all duration-[150ms]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:bg-bg-secondary data-[state=active]:text-text-primary data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
/**
* EN: TabsContent component - Content panel for each tab
* VI: Component TabsContent - Panel nội dung cho mỗi tab
*/
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,164 @@
'use client';
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
/**
* EN: Theme mode type
* VI: Kiểu chế độ theme
*/
export type ThemeMode = 'light' | 'dark' | 'system';
/**
* EN: Theme context value interface
* VI: Interface giá trị context theme
*/
interface ThemeContextValue {
/** EN: Current theme mode / VI: Chế độ theme hiện tại */
theme: ThemeMode;
/** EN: Resolved theme (light or dark) / VI: Theme đã được resolve (light hoặc dark) */
resolvedTheme: 'light' | 'dark';
/** EN: Set theme mode / VI: Đặt chế độ theme */
setTheme: (theme: ThemeMode) => void;
/** EN: Toggle between light and dark / VI: Chuyển đổi giữa light và dark */
toggleTheme: () => void;
}
/**
* EN: Theme context
* VI: Context theme
*/
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
/**
* EN: Get system preference for dark mode
* VI: Lấy preference hệ thống cho dark mode
*/
const getSystemTheme = (): 'light' | 'dark' => {
if (typeof window === 'undefined') return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
/**
* EN: Apply theme class to document
* VI: Áp dụng class theme vào document
*/
const applyTheme = (theme: 'light' | 'dark') => {
if (typeof window === 'undefined') return;
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
root.setAttribute('data-theme', theme);
};
/**
* EN: Theme provider component
* VI: Component provider theme
*
* @param children - Child components / Components con
*/
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
if (typeof window === 'undefined') return 'dark';
// EN: Load from localStorage or default to system
// VI: Load từ localStorage hoặc mặc định là system
const stored = localStorage.getItem('theme') as ThemeMode | null;
if (stored && (stored === 'light' || stored === 'dark' || stored === 'system')) {
return stored === 'system' ? getSystemTheme() : stored;
}
return getSystemTheme();
});
// EN: Initialize theme from localStorage
// VI: Khởi tạo theme từ localStorage
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem('theme') as ThemeMode | null;
if (stored && (stored === 'light' || stored === 'dark' || stored === 'system')) {
setThemeState(stored);
const resolved = stored === 'system' ? getSystemTheme() : stored;
setResolvedTheme(resolved);
applyTheme(resolved);
} else {
// EN: Default to system preference
// VI: Mặc định theo preference hệ thống
const systemTheme = getSystemTheme();
setResolvedTheme(systemTheme);
applyTheme(systemTheme);
}
}, []);
// EN: Listen to system theme changes
// VI: Lắng nghe thay đổi theme hệ thống
useEffect(() => {
if (typeof window === 'undefined' || theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? 'dark' : 'light';
setResolvedTheme(newTheme);
applyTheme(newTheme);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
// EN: Apply theme when resolved theme changes
// VI: Áp dụng theme khi resolved theme thay đổi
useEffect(() => {
applyTheme(resolvedTheme);
}, [resolvedTheme]);
/**
* EN: Set theme mode and persist to localStorage
* VI: Đặt chế độ theme và lưu vào localStorage
*/
const setTheme = useCallback((newTheme: ThemeMode) => {
setThemeState(newTheme);
if (typeof window !== 'undefined') {
localStorage.setItem('theme', newTheme);
}
const resolved = newTheme === 'system' ? getSystemTheme() : newTheme;
setResolvedTheme(resolved);
applyTheme(resolved);
}, []);
/**
* EN: Toggle between light and dark themes
* VI: Chuyển đổi giữa theme light và dark
*/
const toggleTheme = useCallback(() => {
const currentResolved = resolvedTheme;
const newTheme = currentResolved === 'light' ? 'dark' : 'light';
setTheme(newTheme);
}, [resolvedTheme, setTheme]);
const value: ThemeContextValue = {
theme,
resolvedTheme,
setTheme,
toggleTheme,
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
/**
* EN: Hook to access theme context
* VI: Hook để truy cập theme context
*
* @throws Error if used outside ThemeProvider
*/
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider / useTheme phải được sử dụng trong ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,12 @@
import { type ClassValue, clsx } from 'clsx';
/**
* EN: Utility function to merge class names with conditional logic
* VI: Hàm tiện ích để hợp nhất tên class với logic có điều kiện
*
* @param inputs - Class names or conditional class objects / Tên class hoặc object class có điều kiện
* @returns Merged class string / Chuỗi class đã được hợp nhất
*/
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}

View File

@@ -0,0 +1,220 @@
/**
* EN: Design System Theme Tokens
* VI: Các token thiết kế cho Design System
*
* This file contains all CSS custom properties (variables) for the design system,
* including colors, typography, spacing, layout, animations, and more.
* These tokens are used throughout the application and can be referenced in Tailwind CSS
* utility classes via the tailwind.config.js configuration.
*
* File này chứa tất cả các CSS custom properties (biến) cho design system,
* bao gồm màu sắc, typography, spacing, layout, animations, và nhiều hơn nữa.
* Các token này được sử dụng trong toàn bộ ứng dụng và có thể được tham chiếu trong
* các utility classes của Tailwind CSS thông qua cấu hình tailwind.config.js.
*/
:root {
/* ============================================
EN: Color Palette - Dark Mode (Primary Theme)
VI: Bảng màu - Dark Mode (Theme chính)
============================================ */
/* Background Colors / Màu nền */
--bg-primary: #0A0A0A; /* Almost black - Main background */
--bg-secondary: #121212; /* Dark grey - Card/Panel background */
--bg-tertiary: #1A1A1A; /* Dark grey - Hover states */
--bg-elevated: #242424; /* Elevated surfaces (modals, dropdowns) */
/* Text Colors (WCAG Compliant) / Màu chữ (tuân thủ WCAG) */
--text-primary: #FAFAFA; /* Off-white - Primary text (4.5:1 contrast) */
--text-secondary: #E0E0E0; /* Light grey - Secondary text */
--text-tertiary: #A0A0A0; /* Grey - Tertiary/disabled text */
--text-inverse: #1A1A1A; /* Dark - Text on light backgrounds */
/* Brand/Accent Colors / Màu thương hiệu/Accent */
--accent-primary: #3B82F6; /* Primary blue - CTAs, links */
--accent-secondary: #8B5CF6; /* Purple - Highlights */
--accent-success: #10B981; /* Green - Success states */
--accent-warning: #F59E0B; /* Amber - Warnings */
--accent-error: #EF4444; /* Red - Errors */
--accent-info: #06B6D4; /* Cyan - Info */
/* Chat Specific Colors / Màu riêng cho Chat */
--chat-user-bubble: #2563EB; /* Deep blue - User message */
--chat-ai-bubble: #374151; /* Dark grey - AI message */
--chat-user-text: #FFFFFF; /* White text on blue */
--chat-ai-text: #F3F4F6; /* Light text on grey */
--chat-timestamp: #9CA3AF; /* Timestamp grey */
--chat-divider: #1F2937; /* Divider between messages */
/* Border Colors / Màu viền */
--border-primary: #2A2A2A; /* Default borders */
--border-secondary: #3A3A3A; /* Hover borders */
--border-focus: #3B82F6; /* Focus state - Blue */
/* ============================================
EN: Light Mode Colors (Secondary Theme)
VI: Màu sắc cho Light Mode (Theme phụ)
============================================ */
--bg-primary-light: #FFFFFF;
--bg-secondary-light: #F9FAFB;
--bg-tertiary-light: #F3F4F6;
--text-primary-light: #111827;
--text-secondary-light: #4B5563;
--border-primary-light: #E5E7EB;
/* ============================================
EN: Typography
VI: Kiểu chữ
============================================ */
/* Font Stack / Bộ font */
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
/* Type Scale / Kích thước chữ */
--text-6xl: 3.75rem; /* 60px - Hero titles */
--text-5xl: 3rem; /* 48px - Page titles */
--text-4xl: 2.25rem; /* 36px - Section headers */
--text-3xl: 1.875rem; /* 30px - Card headers */
--text-2xl: 1.5rem; /* 24px - Large body */
--text-xl: 1.25rem; /* 20px - Emphasized text */
--text-lg: 1.125rem; /* 18px - Large body */
--text-base: 1rem; /* 16px - Default body */
--text-sm: 0.875rem; /* 14px - Small text */
--text-xs: 0.75rem; /* 12px - Captions */
/* Line Heights / Chiều cao dòng */
--leading-none: 1;
--leading-tight: 1.1;
--leading-snug: 1.2;
--leading-normal: 1.3;
--leading-relaxed: 1.4;
--leading-loose: 1.5;
/* Font Weights / Độ đậm chữ */
--font-light: 300; /* Light text */
--font-normal: 400; /* Body text */
--font-medium: 500; /* Emphasized */
--font-semibold: 600; /* Headings */
--font-bold: 700; /* Strong emphasis */
/* ============================================
EN: Spacing & Layout
VI: Khoảng cách & Bố cục
============================================ */
/* Base Unit: 4px (0.25rem) / Đơn vị cơ sở: 4px (0.25rem) */
--space-0: 0;
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
/* Container Widths / Chiều rộng container */
--container-sm: 640px; /* Small devices */
--container-md: 768px; /* Medium devices */
--container-lg: 1024px; /* Large devices */
--container-xl: 1280px; /* Extra large */
--container-2xl: 1536px; /* 2X large */
--chat-max-width: 768px; /* Max width for chat messages */
--sidebar-width: 280px; /* Conversation history sidebar */
/* Border Radius / Bo góc */
--radius-sm: 0.25rem; /* 4px - Small elements */
--radius-md: 0.5rem; /* 8px - Buttons, inputs */
--radius-lg: 0.75rem; /* 12px - Cards */
--radius-xl: 1rem; /* 16px - Large cards */
--radius-2xl: 1.5rem; /* 24px - Modals */
--radius-full: 9999px; /* Full round - Avatars, pills */
/* ============================================
EN: Shadows (Dark Mode Optimized)
VI: Đổ bóng (Tối ưu cho Dark Mode)
============================================ */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.6);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.7);
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.3); /* Blue glow for focus */
/* ============================================
EN: Grid System & Breakpoints
VI: Hệ thống lưới & Điểm ngắt
============================================ */
--screen-sm: 640px; /* Mobile landscape */
--screen-md: 768px; /* Tablet */
--screen-lg: 1024px; /* Desktop */
--screen-xl: 1280px; /* Large desktop */
--screen-2xl: 1536px; /* Extra large desktop */
/* ============================================
EN: Animation & Transitions
VI: Animation & Chuyển tiếp
============================================ */
/* Timing Functions / Hàm thời gian */
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Duration / Thời lượng */
--duration-fast: 150ms; /* Hover effects */
--duration-normal: 250ms; /* Default transitions */
--duration-slow: 350ms; /* Complex animations */
--duration-slower: 500ms; /* Page transitions */
}
/* ============================================
EN: Light Mode Theme Overrides
VI: Ghi đè theme cho Light Mode
============================================ */
@media (prefers-color-scheme: light) {
:root {
--bg-primary: var(--bg-primary-light);
--bg-secondary: var(--bg-secondary-light);
--bg-tertiary: var(--bg-tertiary-light);
--text-primary: var(--text-primary-light);
--text-secondary: var(--text-secondary-light);
--border-primary: var(--border-primary-light);
}
}
/* ============================================
EN: Dark Mode Theme (Explicit)
VI: Theme Dark Mode (Rõ ràng)
============================================ */
[data-theme="dark"],
.dark {
--bg-primary: #0A0A0A;
--bg-secondary: #121212;
--bg-tertiary: #1A1A1A;
--bg-elevated: #242424;
--text-primary: #FAFAFA;
--text-secondary: #E0E0E0;
--text-tertiary: #A0A0A0;
--border-primary: #2A2A2A;
--border-secondary: #3A3A3A;
}
/* ============================================
EN: Light Mode Theme (Explicit)
VI: Theme Light Mode (Rõ ràng)
============================================ */
[data-theme="light"],
.light {
--bg-primary: var(--bg-primary-light);
--bg-secondary: var(--bg-secondary-light);
--bg-tertiary: var(--bg-tertiary-light);
--text-primary: var(--text-primary-light);
--text-secondary: var(--text-secondary-light);
--border-primary: var(--border-primary-light);
}