feat: Xóa ứng dụng web-admin và cập nhật các controller, DTO, cấu hình dịch vụ cùng các dependency của gói.

This commit is contained in:
Ho Ngoc Hai
2026-01-07 17:28:23 +07:00
parent 935c253c7e
commit 471508baa4
91 changed files with 2904 additions and 10610 deletions

View File

@@ -1,50 +0,0 @@
FROM node:25-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Builder stage
FROM base AS builder
RUN corepack enable pnpm
# Copy workspace configuration
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
# Create directory structure and copy all package.json files
RUN mkdir -p packages apps services
COPY packages/auth-sdk/package.json ./packages/auth-sdk/
COPY packages/http-client/package.json ./packages/http-client/
COPY packages/logger/package.json ./packages/logger/
COPY packages/tracing/package.json ./packages/tracing/
COPY packages/types/package.json ./packages/types/
COPY packages/config/eslint-config/package.json ./packages/config/eslint-config/
COPY packages/config/prettier-config/package.json ./packages/config/prettier-config/
COPY packages/config/tsconfig/package.json ./packages/config/tsconfig/
COPY apps/web-client/package.json ./apps/web-client/
COPY apps/web-admin/package.json ./apps/web-admin/
COPY services/iam-service/package.json ./services/iam-service/
# Install all dependencies for entire monorepo
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Copy all source code
COPY packages ./packages
COPY apps/web-admin ./apps/web-admin
COPY turbo.json ./
# Build using turbo from root (handles dependency order automatically)
RUN pnpm turbo build --filter=web-admin
# Production stage
FROM base AS runner
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy the entire workspace to preserve pnpm structure
COPY --from=builder --chown=nextjs:nodejs /app /app
WORKDIR /app/apps/web-admin
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", ".next/standalone/apps/web-admin/server.js"]

View File

@@ -1,31 +0,0 @@
# Web Admin Application
Next.js web application for GoodGo Platform Admin Panel.
## Features
- Next.js 14 with App Router
- TypeScript
- Tailwind CSS
- Zustand for state management
- API integration with auth service
## Development
```bash
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Build for production
pnpm build
# Start production server
pnpm start
```
## Environment Variables
- `NEXT_PUBLIC_API_URL` - API base URL (default: http://localhost/api/v1)

View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -1,17 +0,0 @@
/** @type {import('next-intl').NextIntlConfig} */
module.exports = {
// EN: Other locales you want to support
// VI: Các ngôn ngữ khác bạn muốn hỗ trợ
locales: ['en', 'vi'],
// EN: Used when no locale matches
// VI: Được sử dụng khi không có locale nào khớp
defaultLocale: 'en',
// EN: Path to the locale files
// VI: Đường dẫn đến các file locale
messages: {
en: './src/i18n/messages/en.json',
vi: './src/i18n/messages/vi.json'
}
};

View File

@@ -1,20 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// EN: Enable React strict mode for development warnings
// VI: Bật React strict mode để hiển thị warnings trong development
reactStrictMode: true,
// EN: Output standalone build for container deployment
// VI: Output build standalone để deploy trong container
output: 'standalone',
// EN: Environment variables exposed to the browser
// VI: Biến môi trường được expose cho browser
env: {
// EN: Public API URL for client-side API calls
// VI: URL API public để gọi API từ client-side
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost/api/v1',
},
};
module.exports = nextConfig;

View File

@@ -1,48 +0,0 @@
{
"name": "@goodgo/web-admin",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@goodgo/http-client": "workspace:*",
"@goodgo/types": "workspace:*",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-tabs": "^1.0.4",
"axios": "^1.6.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.562.0",
"next": "^14.1.0",
"next-intl": "^4.7.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.3",
"recharts": "^2.10.3",
"zod": "^3.22.4",
"zustand": "^5.0.9"
},
"devDependencies": {
"@goodgo/eslint-config": "workspace:*",
"@goodgo/prettier-config": "workspace:*",
"@goodgo/tsconfig": "workspace:*",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^25.0.3",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-next": "^14.1.0",
"postcss": "^8.4.33",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.3"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

View File

@@ -1 +0,0 @@
# Public assets

View File

@@ -1,222 +0,0 @@
'use client';
import * as React from 'react';
// Force dynamic rendering to avoid static generation issues with translations
export const dynamic = 'force-dynamic';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { AnalyticsCard } from '@/components/admin/analytics-card';
import { useTranslation } from '@/hooks/use-translation';
import { BarChart3, Users, MessageSquare, TrendingUp, Clock, AlertCircle, CheckCircle2 } from 'lucide-react';
/**
* EN: Analytics page component
* VI: Component trang Analytics
*
* Features:
* - Overview tab with key metrics
* - Users tab with acquisition and retention
* - Messages tab with activity metrics
* - Performance tab with API metrics
*/
export default function AnalyticsPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
{/* EN: Page header / VI: Header trang */}
<div>
<h1 className="text-3xl font-bold text-text-primary">
{t('admin.analytics.title')}
</h1>
<p className="mt-1 text-sm text-text-tertiary">
{t('admin.analytics.title')}
</p>
</div>
{/* EN: Analytics tabs / VI: Tabs analytics */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">{t('admin.analytics.overview')}</TabsTrigger>
<TabsTrigger value="users">{t('admin.analytics.users')}</TabsTrigger>
<TabsTrigger value="messages">{t('admin.analytics.messages')}</TabsTrigger>
<TabsTrigger value="performance">{t('admin.analytics.performance')}</TabsTrigger>
</TabsList>
{/* EN: Overview tab / VI: Tab tổng quan */}
<TabsContent value="overview" className="space-y-6">
{/* EN: Key metrics / VI: Các chỉ số chính */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<AnalyticsCard
title={t('admin.dashboard.totalUsers')}
value={12543}
change="+12.5%"
trend="up"
icon={Users}
variant="metric"
/>
<AnalyticsCard
title={t('admin.dashboard.messages')}
value={89234}
change="+8.2%"
trend="up"
icon={MessageSquare}
variant="metric"
/>
<AnalyticsCard
title={t('admin.dashboard.activeUsers')}
value={3421}
change="+5.1%"
trend="up"
icon={TrendingUp}
variant="metric"
/>
<AnalyticsCard
title={t('admin.dashboard.revenue')}
value="$45,231"
change="-2.3%"
trend="down"
icon={BarChart3}
variant="metric"
/>
</div>
{/* EN: Trends visualization / VI: Visualization xu hướng */}
<Card>
<CardHeader>
<CardTitle>{t('admin.analytics.trends')}</CardTitle>
<CardDescription>
{t('admin.analytics.trends')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[400px] flex items-center justify-center text-text-tertiary">
{t('common.loading')}
</div>
</CardContent>
</Card>
</TabsContent>
{/* EN: Users tab / VI: Tab người dùng */}
<TabsContent value="users" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('admin.analytics.userAcquisition')}</CardTitle>
<CardDescription>
{t('admin.analytics.userAcquisition')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[400px] flex items-center justify-center text-text-tertiary">
{t('common.loading')}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.analytics.retention')}</CardTitle>
<CardDescription>
{t('admin.analytics.retention')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[400px] flex items-center justify-center text-text-tertiary">
{t('common.loading')}
</div>
</CardContent>
</Card>
</TabsContent>
{/* EN: Messages tab / VI: Tab tin nhắn */}
<TabsContent value="messages" className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<AnalyticsCard
title={t('admin.analytics.totalMessages')}
value={89234}
change="+8.2%"
trend="up"
icon={MessageSquare}
variant="metric"
/>
<AnalyticsCard
title={t('admin.analytics.avgPerUser')}
value="12.5"
change="+2.1%"
trend="up"
icon={Users}
variant="metric"
/>
<AnalyticsCard
title={t('admin.analytics.peakActivity')}
value="14:00"
change="+1h"
trend="up"
icon={Clock}
variant="metric"
/>
</div>
<Card>
<CardHeader>
<CardTitle>{t('admin.analytics.peakActivity')}</CardTitle>
<CardDescription>
{t('admin.analytics.peakActivity')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[400px] flex items-center justify-center text-text-tertiary">
{t('common.loading')}
</div>
</CardContent>
</Card>
</TabsContent>
{/* EN: Performance tab / VI: Tab hiệu suất */}
<TabsContent value="performance" className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<AnalyticsCard
title={t('admin.analytics.apiResponseTimes')}
value="125ms"
change="-5ms"
trend="down"
icon={Clock}
variant="metric"
/>
<AnalyticsCard
title={t('admin.analytics.errorRates')}
value="0.02%"
change="-0.01%"
trend="down"
icon={AlertCircle}
variant="metric"
/>
<AnalyticsCard
title={t('admin.analytics.uptime')}
value="99.9%"
change="+0.1%"
trend="up"
icon={CheckCircle2}
variant="metric"
/>
</div>
<Card>
<CardHeader>
<CardTitle>{t('admin.analytics.apiResponseTimes')}</CardTitle>
<CardDescription>
{t('admin.analytics.apiResponseTimes')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[400px] flex items-center justify-center text-text-tertiary">
{t('common.loading')}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,155 +0,0 @@
'use client';
import * as React from 'react';
// Force dynamic rendering to avoid static generation issues with translations
export const dynamic = 'force-dynamic';
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';
import { useTranslation } from '@/hooks/use-translation';
/**
* 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() {
const { t } = useTranslation();
// 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">
{t('admin.dashboard.title')}
</h1>
<p className="mt-1 text-sm text-text-tertiary">
{t('admin.dashboard.description')}
</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={t('admin.dashboard.totalUsers')}
value={12543}
change="+12.5%"
trend="up"
icon={Users}
variant="metric"
/>
<AnalyticsCard
title={t('admin.dashboard.messages')}
value={89234}
change="+8.2%"
trend="up"
icon={MessageSquare}
variant="metric"
/>
<AnalyticsCard
title={t('admin.dashboard.activeUsers')}
value={3421}
change="+5.1%"
trend="up"
icon={TrendingUp}
variant="metric"
/>
<AnalyticsCard
title={t('admin.dashboard.revenue')}
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">{t('common.loading')}</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={t('admin.dashboard.userGrowth')}
description={t('admin.dashboard.userGrowth')}
/>
</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">{t('common.loading')}</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={t('admin.dashboard.revenueChart')}
description={t('admin.dashboard.revenueChart')}
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

@@ -1,227 +0,0 @@
'use client';
import * as React from 'react';
// Force dynamic rendering to avoid static generation issues with translations
export const dynamic = 'force-dynamic';
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';
import { useTranslation } from '@/hooks/use-translation';
/**
* EN: Admin navigation items configuration
* VI: Cấu hình các mục điều hướng Admin
*/
function getAdminNavItems(t: (key: string) => string) {
return [
{
id: 'dashboard',
label: t('admin.dashboard.title'),
href: '/dashboard',
icon: LayoutDashboard,
},
{
id: 'users',
label: t('admin.users.title'),
href: '/dashboard/users',
icon: Users,
},
{
id: 'analytics',
label: t('admin.analytics.title'),
href: '/dashboard/analytics',
icon: BarChart3,
},
{
id: 'messages',
label: t('admin.messages.title'),
href: '/dashboard/messages',
icon: MessageSquare,
},
{
id: 'settings',
label: t('admin.settings.title'),
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 { t } = useTranslation();
const pathname = usePathname();
const { user, logout } = useAuthStore();
const [sidebarOpen, setSidebarOpen] = React.useState(false);
// EN: Get navigation items with translations / VI: Lấy các mục điều hướng với translations
const adminNavItems = getAdminNavItems(t);
/**
* 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={t('common.adminSidebar')}
>
{/* 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 {t('common.admin')}
</h1>
<button
onClick={() => setSidebarOpen(false)}
className="md:hidden p-2 rounded-md hover:bg-bg-tertiary transition-colors"
aria-label={t('common.closeSidebar')}
>
<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 || t('common.admin')}
</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={t('common.logout')}
>
<LogOut className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
<span>{t('common.logout')}</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={t('common.openSidebar')}
>
<Menu className="h-5 w-5 text-text-secondary" />
</button>
<h1 className="text-lg font-semibold text-text-primary">
GoodGo {t('common.admin')}
</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

@@ -1,174 +0,0 @@
'use client';
import * as React from 'react';
// Force dynamic rendering to avoid static generation issues with translations
export const dynamic = 'force-dynamic';
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';
import { useTranslation } from '@/hooks/use-translation';
import { Filter, Download } from 'lucide-react';
/**
* EN: Message interface
* VI: Interface cho Message
*/
interface Message {
id: string;
content: string;
userId: string;
userName: string;
userEmail: string;
status: 'pending' | 'approved' | 'rejected';
createdAt: string;
conversationId: string;
}
/**
* EN: Messages page component
* VI: Component trang Messages
*
* Features:
* - Search and filter messages
* - Message list with status
* - Content moderation tools
* - Message statistics
*/
export default function MessagesPage() {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = React.useState('');
const [currentPage, setCurrentPage] = React.useState(1);
const [selectedRows, setSelectedRows] = React.useState<string[]>([]);
// EN: Mock data - replace with actual API calls / VI: Dữ liệu mock - thay thế bằng API calls thực tế
const [messages] = React.useState<Message[]>([]);
// EN: Mock columns / VI: Columns mock
const columns: DataTableColumn<Message>[] = [
{ key: 'content', header: t('admin.messages.title'), sortable: true },
{ key: 'userName', header: t('common.user'), sortable: true },
{ key: 'status', header: t('admin.users.status'), sortable: true },
{ key: 'createdAt', header: t('admin.users.createdAt'), sortable: true },
{ key: 'actions', header: t('common.actions'), sortable: false },
];
const handleBulkApprove = (ids: string[]) => {
console.log('Approve messages:', ids);
};
const handleBulkReject = (ids: string[]) => {
console.log('Reject messages:', ids);
};
const handleBulkDelete = (ids: string[]) => {
console.log('Delete messages:', ids);
};
return (
<div className="space-y-6">
{/* EN: Page header / VI: Header trang */}
<div>
<h1 className="text-3xl font-bold text-text-primary">
{t('admin.messages.title')}
</h1>
<p className="mt-1 text-sm text-text-tertiary">
{t('admin.messages.description')}
</p>
</div>
{/* EN: Message statistics / VI: Thống kê tin nhắn */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold text-text-primary">89,234</div>
<p className="text-sm text-text-tertiary mt-1">{t('admin.messages.total')}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold text-text-primary">1,234</div>
<p className="text-sm text-text-tertiary mt-1">{t('admin.messages.today')}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold text-text-primary">8,456</div>
<p className="text-sm text-text-tertiary mt-1">{t('admin.messages.thisWeek')}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold text-text-primary">34,567</div>
<p className="text-sm text-text-tertiary mt-1">{t('admin.messages.thisMonth')}</p>
</CardContent>
</Card>
</div>
{/* EN: Search and filters card / VI: Card tìm kiếm và lọc */}
<Card>
<CardHeader>
<CardTitle>{t('common.search')}</CardTitle>
<CardDescription>
{t('admin.messages.description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="md:col-span-2">
<Input
type="search"
placeholder={t('admin.messages.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="md" className="flex-1">
<Filter className="h-4 w-4 mr-2" />
{t('common.filter')}
</Button>
<Button variant="secondary" size="md">
<Download className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* EN: Message list table / VI: Bảng danh sách tin nhắn */}
<DataTable
data={messages}
columns={columns}
currentPage={currentPage}
itemsPerPage={10}
onPageChange={setCurrentPage}
selectable
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
getRowId={(row) => row.id}
bulkActions={[
{
label: t('admin.messages.approve'),
action: handleBulkApprove,
variant: 'primary',
},
{
label: t('admin.messages.reject'),
action: handleBulkReject,
variant: 'secondary',
},
{
label: t('common.delete'),
action: handleBulkDelete,
variant: 'danger',
},
]}
exportable
onExport={() => console.log('Export messages')}
/>
</div>
);
}

View File

@@ -1,90 +0,0 @@
'use client';
import * as React from 'react';
// Force dynamic rendering to avoid static generation issues with translations
export const dynamic = 'force-dynamic';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from '@/hooks/use-translation';
// 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 })));
const ApiSettings = React.lazy(() => import('@/components/admin/settings/api-settings').then(m => ({ default: m.ApiSettings })));
const AdvancedSettings = React.lazy(() => import('@/components/admin/settings/advanced-settings').then(m => ({ default: m.AdvancedSettings })));
/**
* 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() {
const { t } = useTranslation();
return (
<div className="space-y-6">
{/* EN: Page header / VI: Header trang */}
<div>
<h1 className="text-3xl font-bold text-text-primary">
{t('admin.settings.title')}
</h1>
<p className="mt-1 text-sm text-text-tertiary">
{t('admin.settings.description')}
</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">{t('admin.settings.general')}</TabsTrigger>
<TabsTrigger value="email">{t('admin.settings.email')}</TabsTrigger>
<TabsTrigger value="security">{t('admin.settings.security')}</TabsTrigger>
<TabsTrigger value="api">{t('admin.settings.api')}</TabsTrigger>
<TabsTrigger value="advanced">{t('admin.settings.advanced')}</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">{t('common.loading')}</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">{t('common.loading')}</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">{t('common.loading')}</p></div>}>
<SecuritySettings />
</React.Suspense>
</TabsContent>
{/* EN: API settings tab / VI: Tab cài đặt API */}
<TabsContent value="api" 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">{t('common.loading')}</p></div>}>
<ApiSettings />
</React.Suspense>
</TabsContent>
{/* EN: Advanced settings tab / VI: Tab cài đặt nâng cao */}
<TabsContent value="advanced" 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">{t('common.loading')}</p></div>}>
<AdvancedSettings />
</React.Suspense>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,169 +0,0 @@
'use client';
import * as React from 'react';
// Force dynamic rendering to avoid static generation issues with translations
export const dynamic = 'force-dynamic';
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 { Filter, Download } from 'lucide-react';
import { useTranslation } from '@/hooks/use-translation';
/**
* 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 { t } = useTranslation();
const [searchQuery, setSearchQuery] = React.useState('');
// EN: Mock data - replace with actual API calls / VI: Dữ liệu mock - thay thế bằng API calls thực tế
const [users] = React.useState<User[]>([]);
const [currentPage, setCurrentPage] = React.useState(1);
const [selectedRows, setSelectedRows] = React.useState<string[]>([]);
const [selectedUser, setSelectedUser] = React.useState<User | null>(null);
// EN: Mock columns - replace with actual data / VI: Columns mock - thay thế bằng dữ liệu thực tế
const columns: DataTableColumn<User>[] = [
{ key: 'email', header: t('admin.users.email'), sortable: true },
{ key: 'name', header: t('admin.users.name'), sortable: true },
{ key: 'role', header: t('admin.users.role'), sortable: true },
{ key: 'status', header: t('admin.users.status'), sortable: true },
{ key: 'actions', header: t('common.actions'), sortable: false },
];
const handleBulkActivate = (ids: string[]) => {
console.log('Activate users:', ids);
};
const handleBulkDeactivate = (ids: string[]) => {
console.log('Deactivate users:', ids);
};
const handleBulkDelete = (ids: string[]) => {
console.log('Delete users:', ids);
};
return (
<div className="space-y-6">
{/* EN: Page header / VI: Header trang */}
<div>
<h1 className="text-3xl font-bold text-text-primary">
{t('admin.users.title')}
</h1>
<p className="mt-1 text-sm text-text-tertiary">
{t('admin.users.description')}
</p>
</div>
{/* EN: Search and filters card / VI: Card tìm kiếm và lọc */}
<Card>
<CardHeader>
<CardTitle>{t('admin.users.searchAndFilters')}</CardTitle>
<CardDescription>
{t('admin.users.searchAndFiltersDesc')}
</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={t('admin.users.searchPlaceholder')}
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" />
{t('admin.users.filters')}
</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: t('admin.users.activate'),
action: handleBulkActivate,
variant: 'primary',
},
{
label: t('admin.users.deactivate'),
action: handleBulkDeactivate,
variant: 'secondary',
},
{
label: t('common.delete'),
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,118 +0,0 @@
/**
* EN: Global Styles with Tailwind CSS 4
* VI: Styles toàn cục với Tailwind CSS 4
*
* Import theme variables first, then Tailwind CSS 4
* Import các biến theme trước, sau đó là Tailwind CSS 4
*/
@import "../styles/theme.css";
@import "tailwindcss";
/**
* EN: Base Styles
* VI: Styles cơ bản
*/
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
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);
}
/**
* EN: Focus indicators for keyboard navigation (WCAG 2.1 AA)
* VI: Chỉ báo focus cho điều hướng bàn phím (WCAG 2.1 AA)
*/
*:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
/**
* EN: Skip link styles (screen reader only until focused)
* VI: Styles cho skip link (chỉ screen reader cho đến khi focus)
*/
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.sr-only:focus,
.sr-only:focus-visible {
position: fixed;
width: auto;
height: auto;
padding: inherit;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
}
/**
* EN: Ensure minimum font size for accessibility (16px) - WCAG 2.1 AA
* VI: Đảm bảo kích thước font tối thiểu cho accessibility (16px) - WCAG 2.1 AA
*/
body {
font-size: 16px;
min-font-size: 16px;
}
/**
* EN: Support zoom up to 200% without breaking layout - WCAG 2.1 AA
* VI: Hỗ trợ zoom lên đến 200% mà không làm vỡ layout - WCAG 2.1 AA
*/
html {
zoom: 1;
}
/**
* EN: Ensure all text is zoomable up to 200% - WCAG 2.1 AA
* VI: Đảm bảo tất cả text có thể zoom lên đến 200% - WCAG 2.1 AA
*/
* {
max-width: 100%;
}
/**
* EN: Prevent horizontal scroll on zoom - WCAG 2.1 AA
* VI: Ngăn scroll ngang khi zoom - WCAG 2.1 AA
*/
body {
overflow-x: hidden;
}
}

View File

@@ -1,37 +0,0 @@
import type { Metadata } from 'next';
import './globals.css';
import { ThemeProvider } from '../contexts/theme-context';
import { I18nProvider } from '../providers/i18n-provider';
/**
* EN: Metadata for the application
* VI: Metadata cho ứng dụng
*/
export const metadata: Metadata = {
title: 'GoodGo Platform',
description: 'Enterprise microservices platform',
};
/**
* EN: Root layout component for the entire application
* VI: Component layout gốc cho toàn bộ ứng dụng
*
* @param children - Child components to render / Components con để render
*/
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// EN: Root HTML structure with dynamic language (will be updated by I18nProvider)
// VI: Cấu trúc HTML gốc với ngôn ngữ động (sẽ được cập nhật bởi I18nProvider)
<html lang="en" suppressHydrationWarning>
<body>
<I18nProvider>
<ThemeProvider>{children}</ThemeProvider>
</I18nProvider>
</body>
</html>
);
}

View File

@@ -1,184 +0,0 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useAuthStore } from '@/stores/auth.store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { useTranslation } from '@/hooks/use-translation';
/**
* EN: Create login schema with translated messages
* VI: Tạo login schema với thông báo đã dịch
*/
function createLoginSchema(t: (key: string) => string) {
return z.object({
email: z
.string()
.min(1, t('validation.emailRequired'))
.email(t('validation.email')),
password: z
.string()
.min(1, t('validation.password'))
.min(8, t('validation.passwordMin')),
rememberMe: z.boolean().optional(),
});
}
/**
* EN: Login page component for user authentication
* VI: Component trang đăng nhập để xác thực người dùng
*
* Features:
* - Email/password inputs with validation
* - Real-time error messages
* - Remember me checkbox
* - Loading state on button
* - Error handling
*/
export default function LoginPage() {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
// EN: Next.js router for navigation
// VI: Next.js router để điều hướng
const router = useRouter();
// EN: Auth store hooks for login functionality
// VI: Auth store hooks cho chức năng đăng nhập
const { login, isLoading } = useAuthStore();
// EN: General error state for API errors
// VI: Trạng thái lỗi chung cho lỗi API
const [apiError, setApiError] = useState<string>('');
// EN: Create schema with translations / VI: Tạo schema với translations
const loginSchema = createLoginSchema(t);
type LoginFormData = z.infer<typeof loginSchema>;
// EN: React Hook Form setup with Zod resolver
// VI: Setup React Hook Form với Zod resolver
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
/**
* EN: Handle form submission for login
* VI: Xử lý submit form để đăng nhập
*
* @param data - Form data validated by Zod / Dữ liệu form đã được validate bởi Zod
*/
const onSubmit = async (data: LoginFormData) => {
setApiError('');
try {
// EN: Attempt login through auth store
// VI: Thử đăng nhập thông qua auth store
await login(data.email, data.password);
// EN: Redirect to dashboard on successful login
// VI: Chuyển hướng về dashboard khi đăng nhập thành công
router.push('/dashboard');
} catch (err: any) {
// EN: Set error message from API response
// VI: Đặt thông báo lỗi từ phản hồi API
setApiError(err.message || t('auth.login.loginFailed'));
}
};
return (
// EN: Centered login form layout
// VI: Layout form đăng nhập được căn giữa
<main role="main" aria-label={t('auth.login.pageLabel')}>
<div className="min-h-screen flex items-center justify-center bg-bg-primary px-4 py-12">
<Card className="w-full max-w-md" hover={false} bordered>
<CardHeader className="text-center">
<CardTitle className="text-2xl font-semibold">
{t('auth.login.title')}
</CardTitle>
<CardDescription className="mt-2">
{t('auth.login.description')}
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
{/* EN: API Error message display / VI: Hiển thị thông báo lỗi API */}
{apiError && (
<div
className="p-3 rounded-md bg-bg-tertiary border border-accent-error text-accent-error text-sm flex items-center gap-2"
role="alert"
>
<svg
className="h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{apiError}</span>
</div>
)}
{/* EN: Email input field / VI: Trường nhập email */}
<Input
type="email"
label={t('auth.login.email')}
placeholder={t('auth.login.email')}
{...register('email')}
errorMessage={errors.email?.message}
validationState={errors.email ? 'error' : 'default'}
autoComplete="email"
aria-required="true"
/>
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
<Input
type="password"
label={t('auth.login.password')}
placeholder={t('auth.login.password')}
{...register('password')}
errorMessage={errors.password?.message}
validationState={errors.password ? 'error' : 'default'}
autoComplete="current-password"
aria-required="true"
/>
{/* EN: Submit button / VI: Nút submit */}
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={isSubmitting || isLoading}
loading={isSubmitting || isLoading}
>
{isSubmitting || isLoading
? t('auth.login.signingIn')
: t('auth.login.title')}
</Button>
</CardContent>
</form>
</Card>
</div>
</main>
);
}

View File

@@ -1,34 +0,0 @@
'use client';
import { useAuthStore } from '@/stores/auth.store';
import { useEffect } from 'react';
export default function Home() {
const { user, isAuthenticated, isLoading, fetchUser } = useAuthStore();
useEffect(() => {
if (!isAuthenticated && !isLoading) {
fetchUser();
}
}, [isAuthenticated, isLoading, fetchUser]);
if (isLoading) {
return <div className="p-8">Loading...</div>;
}
return (
<main className="min-h-screen p-8">
<h1 className="text-4xl font-bold mb-4">GoodGo Platform</h1>
{isAuthenticated && user ? (
<div>
<p>Welcome, {user.email}!</p>
<p>Role: {user.role}</p>
</div>
) : (
<div>
<p>Please log in to continue.</p>
</div>
)}
</main>
);
}

View File

@@ -1,233 +0,0 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
import { useTranslation } from '@/hooks/use-translation';
/**
* 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) {
const { locale } = useTranslation();
// 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(locale === 'vi' ? 'vi-VN' : 'en-US') : 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(locale === 'vi' ? 'vi-VN' : 'en-US')
: comp.value}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,176 +0,0 @@
'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';
import { useTranslation } from '@/hooks/use-translation';
/**
* 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,
description,
currency = '$',
showLegend = true,
className,
}: RevenueChartProps) {
const { t, locale } = useTranslation();
const defaultTitle = t('admin.dashboard.revenue');
const defaultDescription = t('admin.dashboard.revenue');
// EN: Format currency value / VI: Format giá trị tiền tệ
const formatCurrency = (value: number) => {
return `${currency}${value.toLocaleString(locale === 'vi' ? 'vi-VN' : 'en-US')}`;
};
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title || defaultTitle}</CardTitle>
{(description || defaultDescription) && <CardDescription>{description || defaultDescription}</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={t('admin.dashboard.revenue')}
/>
{data.some((d) => d.previousRevenue !== undefined) && (
<Area
type="monotone"
dataKey="previousRevenue"
stroke="var(--accent-secondary)"
strokeWidth={2}
fill="url(#previousRevenueGradient)"
name={t('admin.dashboard.revenue')}
/>
)}
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -1,150 +0,0 @@
'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';
import { useTranslation } from '@/hooks/use-translation';
/**
* 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,
description,
showLegend = true,
className,
}: UserGrowthChartProps) {
const { t } = useTranslation();
const defaultTitle = t('admin.dashboard.userGrowth');
const defaultDescription = t('admin.dashboard.userGrowth');
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title || defaultTitle}</CardTitle>
{(description || defaultDescription) && <CardDescription>{description || defaultDescription}</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={t('admin.dashboard.totalUsers')}
/>
{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={t('admin.dashboard.totalUsers')}
/>
)}
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -1,492 +0,0 @@
'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,
CheckSquare,
Square,
} from 'lucide-react';
import { useTranslation } from '@/hooks/use-translation';
/**
* 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 { t } = useTranslation();
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={t('common.search')}
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" />
{t('common.export')}
</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} {t('common.selected')}
</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">
{t('common.loading')}
</p>
</div>
) : processedData.length === 0 ? (
<div className="p-8 text-center">
<p className="text-sm text-text-tertiary">
{t('common.noData')}
</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={t('common.selectAll')}
>
{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={`${t('common.filter')}: ${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={`${t('common.selectAll')}: ${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 ? (
<>
{t('common.showing')}{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
{t('common.to')}{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, totalItems)}
</span>{' '}
{t('common.of')}{' '}
<span className="font-medium text-text-secondary">{totalItems}</span>{' '}
{t('common.results')}
</>
) : (
<>
{t('common.showing')}{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
{t('common.to')}{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, data.length)}
</span>{' '}
{t('common.of')}{' '}
<span className="font-medium text-text-secondary">{data.length}</span>{' '}
{t('common.results')}
</>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
aria-label={t('common.firstPage')}
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={t('common.previous')}
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">
{t('common.page')} {currentPage} {t('common.of')} {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
aria-label={t('common.next')}
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={t('common.lastPage')}
className="min-w-[44px] min-h-[44px]"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,561 +0,0 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/use-translation';
/**
* 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, locale: string = 'en'): 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);
// EN: Use locale-aware formatting / VI: Sử dụng formatting theo locale
if (diffMins < 1) return locale === 'vi' ? 'Vừa xong' : 'Just now';
if (diffMins < 60) {
return locale === 'vi' ? `${diffMins} phút trước` : `${diffMins}m ago`;
}
if (diffHours < 24) {
return locale === 'vi' ? `${diffHours} giờ trước` : `${diffHours}h ago`;
}
if (diffDays < 7) {
return locale === 'vi' ? `${diffDays} ngày trước` : `${diffDays}d ago`;
}
return activityDate.toLocaleDateString(locale === 'vi' ? 'vi-VN' : '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, t: (key: string) => string): string {
const labels: Record<ActivityStatus, string> = {
success: t('admin.activity.status.success'),
warning: t('admin.activity.status.warning'),
error: t('admin.activity.status.error'),
info: t('admin.activity.status.info'),
pending: t('admin.activity.status.pending'),
};
return labels[status];
}
/**
* EN: Get action label
* VI: Lấy label cho action
*/
function getActionLabel(action: ActivityAction, t: (key: string) => string): string {
const labels: Record<ActivityAction, string> = {
user_created: t('admin.activity.actions.userCreated'),
user_updated: t('admin.activity.actions.userUpdated'),
user_deleted: t('admin.activity.actions.userDeleted'),
user_login: t('admin.activity.actions.userLogin'),
user_logout: t('admin.activity.actions.userLogout'),
message_sent: t('admin.activity.actions.messageSent'),
message_deleted: t('admin.activity.actions.messageDeleted'),
settings_updated: t('admin.activity.actions.settingsUpdated'),
system_backup: t('admin.activity.actions.systemBackup'),
system_restore: t('admin.activity.actions.systemRestore'),
};
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) {
const { t, locale } = useTranslation();
// 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">
{t('admin.activity.recentActivity')}
</h3>
<p className="text-sm text-text-tertiary mt-1">
{t('admin.activity.latestSystemActivities')}
</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">
{t('common.loading')}
</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">
{t('admin.activity.noActivity')}
</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">
{t('common.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
{t('common.actions')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
{t('admin.users.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
{t('admin.users.lastLogin')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-tertiary uppercase tracking-wider">
{t('common.actions')}
</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={`${t('common.user')}: ${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, t)}
</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, t)}
</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, locale)}
</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={t('admin.users.viewDetails')}
>
{t('admin.users.viewDetails')}
</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={t('common.copy')}
>
{t('common.copy')}
</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 ? (
<>
{t('common.showing')}{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
{t('common.to')}{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, totalItems)}
</span>{' '}
{t('common.of')}{' '}
<span className="font-medium text-text-secondary">{totalItems}</span>{' '}
{t('common.results')}
</>
) : (
<>
{t('common.showing')}{' '}
<span className="font-medium text-text-secondary">
{startIndex + 1}
</span>{' '}
{t('common.to')}{' '}
<span className="font-medium text-text-secondary">
{Math.min(endIndex, activities.length)}
</span>{' '}
{t('common.of')}{' '}
<span className="font-medium text-text-secondary">
{activities.length}
</span>{' '}
{t('common.results')}
</>
)}
</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={t('common.previous')}
>
<span className="hidden sm:inline">{t('common.previous')}</span>
<span className="sm:hidden">{t('common.previous')}</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={`${t('common.page')} ${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={t('common.next')}
>
<span className="hidden sm:inline">{t('common.next')}</span>
<span className="sm:hidden">{t('common.next')}</span>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,208 +0,0 @@
'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';
import { useTranslation } from '@/hooks/use-translation';
/**
* EN: Create advanced settings schema with translated messages
* VI: Tạo schema cài đặt nâng cao với thông báo đã dịch
*/
function createAdvancedSettingsSchema(_t: (key: string) => string) {
return z.object({
enableFeatureFlags: z.boolean(),
logLevel: z.enum(['debug', 'info', 'warn', 'error']),
cacheTtl: z.number().min(60).max(86400),
databasePoolSize: z.number().min(1).max(100),
});
}
/**
* EN: Advanced Settings component
* VI: Component Advanced Settings
*/
export function AdvancedSettings() {
const { t } = useTranslation();
const [isSaving, setIsSaving] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
// EN: Create schema with translations / VI: Tạo schema với translations
const advancedSettingsSchema = createAdvancedSettingsSchema(t);
type AdvancedSettingsFormData = z.infer<typeof advancedSettingsSchema>;
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
watch,
setValue,
} = useForm<AdvancedSettingsFormData>({
resolver: zodResolver(advancedSettingsSchema),
defaultValues: {
enableFeatureFlags: true,
logLevel: 'info',
cacheTtl: 3600,
databasePoolSize: 10,
},
});
const enableFeatureFlags = watch('enableFeatureFlags');
// EN: Handle form submission / VI: Xử lý submit form
const onSubmit = async (data: AdvancedSettingsFormData) => {
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(t('admin.settings.settingsSaveFailed'), error);
} finally {
setIsSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.advanced')}</CardTitle>
<CardDescription>
{t('admin.settings.advancedDesc')}
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
{/* EN: Feature Flags / VI: Cờ tính năng */}
<div className="flex items-center justify-between p-4 rounded-lg bg-bg-tertiary border border-border-primary">
<div className="flex-1">
<label
htmlFor="feature-flags"
className="text-sm font-medium text-text-primary cursor-pointer"
>
{t('admin.settings.featureFlags')}
</label>
<p className="text-sm text-text-tertiary mt-1">
{t('admin.settings.featureFlags')}
</p>
</div>
<Switch
id="feature-flags"
checked={enableFeatureFlags}
onCheckedChange={(checked) => setValue('enableFeatureFlags', checked)}
/>
</div>
{/* EN: System Logs / VI: Nhật ký hệ thống */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
{t('admin.settings.systemLogs')}
</h3>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
{t('admin.settings.systemLogs')}
</label>
<select
{...register('logLevel')}
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="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
</div>
</div>
{/* EN: Cache Management / VI: Quản lý cache */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
{t('admin.settings.cacheManagement')}
</h3>
<Input
label={t('admin.settings.cacheManagement')}
type="number"
placeholder="3600"
{...register('cacheTtl', { valueAsNumber: true })}
errorMessage={errors.cacheTtl?.message}
validationState={errors.cacheTtl ? 'error' : 'default'}
helperText={t('admin.settings.cacheManagement')}
/>
</div>
{/* EN: Database / VI: Cơ sở dữ liệu */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
{t('admin.settings.database')}
</h3>
<Input
label={t('admin.settings.database')}
type="number"
placeholder="10"
{...register('databasePoolSize', { valueAsNumber: true })}
errorMessage={errors.databasePoolSize?.message}
validationState={errors.databasePoolSize ? 'error' : 'default'}
/>
</div>
{/* EN: Backup & Restore / VI: Sao lưu & Khôi phục */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
{t('admin.settings.backup')}
</h3>
<div className="flex gap-2">
<Button variant="secondary" size="sm" type="button">
{t('admin.settings.backup')}
</Button>
<Button variant="secondary" size="sm" type="button">
{t('admin.settings.restore')}
</Button>
</div>
</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>
{t('admin.settings.settingsSaved')}
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
{isSaving ? t('admin.settings.saving') : t('admin.settings.saveSettings')}
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -1,203 +0,0 @@
'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';
import { useTranslation } from '@/hooks/use-translation';
/**
* EN: Create API settings schema with translated messages
* VI: Tạo schema cài đặt API với thông báo đã dịch
*/
function createApiSettingsSchema(t: (key: string) => string) {
return z.object({
apiKeyPrefix: z.string().min(1, t('validation.required')),
webhookUrl: z.string().url(t('validation.invalidFormat')).optional(),
rateLimitRequests: z.number().min(10).max(10000),
rateLimitWindow: z.number().min(1).max(60),
enableRateLimiting: z.boolean(),
});
}
/**
* EN: API Settings component
* VI: Component API Settings
*/
export function ApiSettings() {
const { t } = useTranslation();
const [isSaving, setIsSaving] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
// EN: Create schema with translations / VI: Tạo schema với translations
const apiSettingsSchema = createApiSettingsSchema(t);
type ApiSettingsFormData = z.infer<typeof apiSettingsSchema>;
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
watch,
setValue,
} = useForm<ApiSettingsFormData>({
resolver: zodResolver(apiSettingsSchema),
defaultValues: {
apiKeyPrefix: 'sk_',
webhookUrl: '',
rateLimitRequests: 100,
rateLimitWindow: 1,
enableRateLimiting: true,
},
});
const enableRateLimiting = watch('enableRateLimiting');
// EN: Handle form submission / VI: Xử lý submit form
const onSubmit = async (data: ApiSettingsFormData) => {
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(t('admin.settings.settingsSaveFailed'), error);
} finally {
setIsSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.api')}</CardTitle>
<CardDescription>
{t('admin.settings.apiDesc')}
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
{/* EN: API Keys / VI: Khóa API */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
{t('admin.settings.apiKeys')}
</h3>
<Input
label={t('admin.settings.apiKeys')}
placeholder="sk_"
{...register('apiKeyPrefix')}
errorMessage={errors.apiKeyPrefix?.message}
validationState={errors.apiKeyPrefix ? 'error' : 'default'}
helperText={t('admin.settings.apiKeys')}
/>
<Button variant="secondary" size="sm" type="button">
{t('admin.settings.createApiKey')}
</Button>
</div>
{/* EN: Webhooks / VI: Webhooks */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">
{t('admin.settings.webhooks')}
</h3>
<Input
label={t('admin.settings.webhooks')}
placeholder="https://example.com/webhook"
{...register('webhookUrl')}
errorMessage={errors.webhookUrl?.message}
validationState={errors.webhookUrl ? 'error' : 'default'}
/>
</div>
{/* EN: Rate Limits / VI: Giới hạn tốc độ */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-text-primary">
{t('admin.settings.rateLimits')}
</h3>
<p className="text-sm text-text-tertiary mt-1">
{t('admin.settings.rateLimits')}
</p>
</div>
<Switch
checked={enableRateLimiting}
onCheckedChange={(checked) => setValue('enableRateLimiting', checked)}
/>
</div>
{enableRateLimiting && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
label={t('admin.settings.rateLimits')}
type="number"
placeholder="100"
{...register('rateLimitRequests', { valueAsNumber: true })}
errorMessage={errors.rateLimitRequests?.message}
validationState={errors.rateLimitRequests ? 'error' : 'default'}
/>
<Input
label={t('admin.settings.rateLimits')}
type="number"
placeholder="1"
{...register('rateLimitWindow', { valueAsNumber: true })}
errorMessage={errors.rateLimitWindow?.message}
validationState={errors.rateLimitWindow ? 'error' : 'default'}
/>
</div>
)}
</div>
{/* EN: Documentation / VI: Tài liệu */}
<div className="p-4 rounded-lg bg-bg-tertiary border border-border-primary">
<h4 className="text-sm font-medium text-text-primary mb-2">
{t('admin.settings.documentation')}
</h4>
<p className="text-sm text-text-tertiary">
{t('admin.settings.documentation')}
</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>
{t('admin.settings.settingsSaved')}
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
{isSaving ? t('admin.settings.saving') : t('admin.settings.saveSettings')}
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -1,256 +0,0 @@
'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 { useTranslation } from '@/hooks/use-translation';
/**
* EN: Create email settings schema with translated messages
* VI: Tạo schema cài đặt email với thông báo đã dịch
*/
function createEmailSettingsSchema(t: (key: string) => string) {
return z.object({
smtpHost: z.string().min(1, t('validation.required')),
smtpPort: z.number().min(1).max(65535),
smtpUser: z.string().min(1, t('validation.required')),
smtpPassword: z.string().min(1, t('validation.required')),
smtpFromEmail: z.string().email(t('validation.email')),
smtpFromName: z.string().min(1, t('validation.required')),
});
}
/**
* 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 { t } = useTranslation();
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);
// EN: Create schema with translations / VI: Tạo schema với translations
const emailSettingsSchema = createEmailSettingsSchema(t);
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
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(t('admin.settings.settingsSaveFailed'), 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}
>
{t('admin.settings.sendTestEmail')}
</Button>
</div>
{testResult === 'success' && (
<p className="text-sm text-accent-success mt-2">
{t('admin.settings.testEmailSent')}
</p>
)}
{testResult === 'error' && (
<p className="text-sm text-accent-error mt-2">
{t('admin.settings.testEmailFailed')}
</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>
{t('admin.settings.settingsSaved')}
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
{isSaving ? t('admin.settings.saving') : t('admin.settings.saveSettings')}
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -1,233 +0,0 @@
'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';
import { Select } from '@/components/ui/select';
import { useTranslation } from '@/hooks/use-translation';
import { useI18n } from '@/contexts/i18n-context';
/**
* EN: Create general settings schema with translated messages
* VI: Tạo schema cài đặt chung với thông báo đã dịch
*/
function createGeneralSettingsSchema(t: (key: string) => string) {
return z.object({
siteName: z.string().min(1, t('validation.siteNameRequired')),
defaultLanguage: z.enum(['en', 'vi'], {
required_error: t('validation.languageRequired'),
}),
timezone: z.string().min(1, t('validation.timezoneRequired')),
maintenanceMode: z.boolean(),
});
}
/**
* 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 { t } = useTranslation();
const { locale, setLocale } = useI18n();
const [isSaving, setIsSaving] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
// EN: Create schema with translations / VI: Tạo schema với translations
const generalSettingsSchema = createGeneralSettingsSchema(t);
type GeneralSettingsFormData = z.infer<typeof generalSettingsSchema>;
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(t('admin.settings.settingsSaveFailed'), error);
} finally {
setIsSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.general')}</CardTitle>
<CardDescription>
{t('admin.settings.generalDesc')}
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
{/* EN: Site name / VI: Tên site */}
<Input
label={t('admin.settings.siteName')}
placeholder={t('admin.settings.siteNamePlaceholder')}
{...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">
{t('admin.settings.siteLogo')}
</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">
{t('admin.settings.uploadLogo')}
</Button>
<p className="text-xs text-text-tertiary mt-1">
{t('admin.settings.logoRecommendation')}
</p>
</div>
</div>
</div>
{/* EN: Interface language / VI: Ngôn ngữ giao diện */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
{t('admin.settings.interfaceLanguage')}
</label>
<Select
value={locale}
onChange={(e) => {
const newLocale = e.target.value as 'en' | 'vi';
if (newLocale === 'en' || newLocale === 'vi') {
setLocale(newLocale);
}
}}
helperText={t('admin.settings.interfaceLanguageDesc')}
>
<option value="en">English</option>
<option value="vi">Tiếng Việt</option>
</Select>
</div>
{/* EN: Default language / VI: Ngôn ngữ mặc định */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
{t('admin.settings.defaultLanguage')}
</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={t('admin.settings.timezone')}
placeholder="UTC"
{...register('timezone')}
errorMessage={errors.timezone?.message}
validationState={errors.timezone ? 'error' : 'default'}
helperText={t('admin.settings.timezoneHelper')}
/>
{/* 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"
>
{t('admin.settings.maintenanceMode')}
</label>
<p className="text-sm text-text-tertiary mt-1">
{t('admin.settings.maintenanceModeDesc')}
</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>
{t('admin.settings.settingsSaved')}
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
{isSaving ? t('admin.settings.saving') : t('admin.settings.saveSettings')}
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -1,279 +0,0 @@
'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';
import { useTranslation } from '@/hooks/use-translation';
/**
* EN: Create security settings schema with translated messages
* VI: Tạo schema cài đặt bảo mật với thông báo đã dịch
*/
function createSecuritySettingsSchema(_t: (key: string) => string) {
return 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
});
}
/**
* 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 { t } = useTranslation();
const [isSaving, setIsSaving] = React.useState(false);
const [saveSuccess, setSaveSuccess] = React.useState(false);
// EN: Create schema with translations / VI: Tạo schema với translations
const securitySettingsSchema = createSecuritySettingsSchema(t);
type SecuritySettingsFormData = z.infer<typeof securitySettingsSchema>;
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(t('admin.settings.settingsSaveFailed'), error);
} finally {
setIsSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.security')}</CardTitle>
<CardDescription>
{t('admin.settings.securityDesc')}
</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">
{t('admin.settings.passwordPolicy')}
</h3>
<Input
label={t('admin.settings.minLength')}
type="number"
{...register('minPasswordLength', { valueAsNumber: true })}
errorMessage={errors.minPasswordLength?.message}
validationState={errors.minPasswordLength ? 'error' : 'default'}
helperText={t('admin.settings.minLength')}
/>
<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">
{t('admin.settings.requireUppercase')}
</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">
{t('admin.settings.requireLowercase')}
</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">
{t('admin.settings.requireNumbers')}
</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">
{t('admin.settings.requireSpecialChars')}
</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">
{t('admin.settings.sessionTimeout')}
</h3>
<Input
label={t('admin.settings.sessionTimeout')}
type="number"
{...register('sessionTimeout', { valueAsNumber: true })}
errorMessage={errors.sessionTimeout?.message}
validationState={errors.sessionTimeout ? 'error' : 'default'}
/>
<Input
label={t('admin.settings.sessionTimeout')}
type="number"
{...register('maxLoginAttempts', { valueAsNumber: true })}
errorMessage={errors.maxLoginAttempts?.message}
validationState={errors.maxLoginAttempts ? 'error' : 'default'}
/>
<Input
label={t('admin.settings.sessionTimeout')}
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">
{t('admin.settings.rateLimits')}
</label>
<p className="text-sm text-text-tertiary mt-1">
{t('admin.settings.rateLimits')}
</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={t('admin.settings.rateLimits')}
type="number"
{...register('rateLimitRequests', { valueAsNumber: true })}
errorMessage={errors.rateLimitRequests?.message}
validationState={errors.rateLimitRequests ? 'error' : 'default'}
/>
<Input
label={t('admin.settings.rateLimits')}
type="number"
{...register('rateLimitWindow', { valueAsNumber: true })}
errorMessage={errors.rateLimitWindow?.message}
validationState={errors.rateLimitWindow ? 'error' : 'default'}
/>
</div>
)}
</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>
{t('admin.settings.settingsSaved')}
</div>
)}
</CardContent>
<CardFooter className="flex justify-end">
<Button
type="submit"
variant="primary"
loading={isSaving}
disabled={!isDirty || isSaving}
>
{isSaving ? t('admin.settings.saving') : t('admin.settings.saveSettings')}
</Button>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -1,350 +0,0 @@
'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';
import { useTranslation } from '@/hooks/use-translation';
/**
* 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) {
const { t, locale } = useTranslation();
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(locale === 'vi' ? 'vi-VN' : '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>{t('admin.users.viewDetails')}</DialogTitle>
<DialogDescription>
{t('admin.users.description')}
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="profile" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="activity">{t('admin.activity.recentActivity')}</TabsTrigger>
<TabsTrigger value="messages">{t('admin.messages.title')}</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 === 'active'
? t('admin.users.active')
: user.status === 'inactive'
? t('admin.users.inactive')
: t('admin.users.banned')}
</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">{t('admin.users.role')}</p>
<p className="text-sm font-medium text-text-primary">{user.role}</p>
</div>
<div>
<p className="text-xs text-text-tertiary mb-1">
{t('admin.users.createdAt')}
</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">
{t('admin.users.lastLogin')}
</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" />
{t('admin.users.editUser')}
</Button>
)}
{onDeactivate && (
<Button
variant="secondary"
size="sm"
onClick={() => onDeactivate(user.id)}
>
<Ban className="h-4 w-4 mr-2" />
{user.status === 'active' ? t('admin.users.deactivate') : t('admin.users.activate')}
</Button>
)}
{onDelete && (
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm(t('admin.users.deleteUser'))) {
onDelete(user.id);
}
}}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('common.delete')}
</Button>
)}
</div>
</TabsContent>
{/* EN: Activity tab / VI: Tab Activity */}
<TabsContent value="activity" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{t('admin.activity.recentActivity')}</CardTitle>
</CardHeader>
<CardContent>
{activityLogs.length === 0 ? (
<p className="text-sm text-text-tertiary text-center py-8">
{t('admin.activity.noActivity')}
</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>{t('admin.messages.title')}</CardTitle>
</CardHeader>
<CardContent>
{messageHistory.length === 0 ? (
<p className="text-sm text-text-tertiary text-center py-8">
{t('common.noData')}
</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

@@ -1,62 +0,0 @@
'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

@@ -1,135 +0,0 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
/**
* EN: Avatar size variants
* VI: Các biến thể kích thước Avatar
*/
const avatarSizes = {
xs: 'h-6 w-6 text-xs', // 24px
sm: 'h-8 w-8 text-sm', // 32px
md: 'h-10 w-10 text-base', // 40px (default)
lg: 'h-12 w-12 text-lg', // 48px
xl: 'h-16 w-16 text-xl', // 64px
};
/**
* EN: Status indicator size variants (relative to avatar size)
* VI: Các biến thể kích thước status indicator (tương đối với kích thước avatar)
*/
const statusSizes = {
xs: 'h-1.5 w-1.5 border-[1.5px]',
sm: 'h-2 w-2 border-2',
md: 'h-2.5 w-2.5 border-2',
lg: 'h-3 w-3 border-[2.5px]',
xl: 'h-3.5 w-3.5 border-[3px]',
};
/**
* EN: Status indicator positions (relative to avatar size)
* VI: Vị trí status indicator (tương đối với kích thước avatar)
*/
const statusPositions = {
xs: 'bottom-0 right-0',
sm: 'bottom-0 right-0',
md: 'bottom-0 right-0',
lg: 'bottom-0.5 right-0.5',
xl: 'bottom-1 right-1',
};
/**
* EN: Avatar status color variants
* VI: Các biến thể màu status của Avatar
*/
const statusColors = {
online: 'bg-accent-success border-bg-primary',
offline: 'bg-text-tertiary border-bg-primary',
away: 'bg-accent-warning border-bg-primary',
busy: 'bg-accent-error border-bg-primary',
};
/**
* EN: Avatar component - User avatar with image, fallback, and status indicator
* VI: Component Avatar - Avatar người dùng với hình ảnh, fallback và status indicator
*
* @param size - Avatar size variant / Biến thể kích thước avatar
* @param status - Status indicator (online/offline/away/busy) / Status indicator
* @param className - Additional CSS classes / Các class CSS bổ sung
*/
type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
type AvatarStatus = 'online' | 'offline' | 'away' | 'busy';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & {
size?: AvatarSize;
status?: AvatarStatus;
}
>(({ className, size = 'md', status, ...props }, ref) => (
<div className="relative inline-block">
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex shrink-0 overflow-hidden rounded-full bg-bg-tertiary',
avatarSizes[size],
className
)}
{...props}
/>
{status && (
<span
className={cn(
'absolute rounded-full border-2',
statusSizes[size],
statusPositions[size],
statusColors[status]
)}
aria-label={`Status: ${status} / Trạng thái: ${status}`}
/>
)}
</div>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
/**
* EN: AvatarImage component - Image displayed in avatar
* VI: Component AvatarImage - Hình ảnh hiển thị trong avatar
*/
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
alt?: string;
}
>(({ className, alt, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full object-cover', className)}
alt={alt || 'User avatar / Avatar người dùng'}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
/**
* EN: AvatarFallback component - Fallback content when image fails to load
* VI: Component AvatarFallback - Nội dung fallback khi hình ảnh không tải được
*/
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-chat-ai-bubble text-text-primary font-medium',
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

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

View File

@@ -1,132 +0,0 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
/**
* EN: Button component variants configuration using class-variance-authority
* VI: Cấu hình các biến thể của component Button sử dụng class-variance-authority
*/
const buttonVariants = cva(
// EN: Base styles for all button variants / VI: Styles cơ bản cho tất cả các biến thể button
'inline-flex items-center justify-center rounded-md font-medium transition-all duration-[150ms] ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]',
{
variants: {
variant: {
// EN: Primary button - main CTA style / VI: Button chính - style CTA chính
primary:
'bg-accent-primary text-white hover:brightness-110 hover:scale-[1.02] active:scale-[0.98] active:brightness-90 focus-visible:ring-accent-primary focus-visible:shadow-[0_0_20px_rgba(59,130,246,0.3)] shadow-md hover:shadow-lg',
// EN: Secondary button - alternative style / VI: Button phụ - style thay thế
secondary:
'bg-chat-ai-bubble text-chat-ai-text hover:bg-bg-tertiary hover:scale-[1.02] active:scale-[0.98] active:brightness-90 focus-visible:ring-accent-primary focus-visible:shadow-[0_0_20px_rgba(59,130,246,0.3)] border border-border-primary',
// EN: Ghost button - minimal style / VI: Button ghost - style tối giản
ghost:
'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary active:bg-bg-elevated focus-visible:ring-accent-primary',
// EN: Danger button - destructive actions / VI: Button nguy hiểm - hành động phá hủy
danger:
'bg-accent-error text-white hover:brightness-110 hover:scale-[1.02] active:scale-[0.98] active:brightness-90 focus-visible:ring-accent-error focus-visible:shadow-[0_0_20px_rgba(239,68,68,0.3)] shadow-md hover:shadow-lg',
},
size: {
// EN: Extra small button - 28px height (mobile: min 44px) / VI: Button cực nhỏ - chiều cao 28px (mobile: tối thiểu 44px)
xs: 'h-7 px-2 text-xs min-h-[44px] min-w-[44px]',
// EN: Small button - 32px height (mobile: min 44px) / VI: Button nhỏ - chiều cao 32px (mobile: tối thiểu 44px)
sm: 'h-8 px-3 text-sm min-h-[44px] min-w-[44px]',
// EN: Medium button (default) - 40px height (mobile: min 44px) / VI: Button trung bình (mặc định) - chiều cao 40px (mobile: tối thiểu 44px)
md: 'h-10 px-4 text-base min-h-[44px] min-w-[44px]',
// EN: Large button - 48px height / VI: Button lớn - chiều cao 48px
lg: 'h-12 px-6 text-lg min-h-[44px] min-w-[44px]',
// EN: Extra large button - 56px height / VI: Button cực lớn - chiều cao 56px
xl: 'h-14 px-8 text-xl min-h-[44px] min-w-[44px]',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
/**
* EN: Button component props interface
* VI: Interface cho props của component Button
*/
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
/**
* EN: Loading state - shows spinner when true / VI: Trạng thái loading - hiển thị spinner khi true
*/
loading?: boolean;
/**
* EN: Button content / VI: Nội dung button
*/
children?: React.ReactNode;
}
/**
* EN: Button component with variants and sizes
* VI: Component Button với các biến thể và kích thước
*
* @example
* ```tsx
* <Button variant="primary" size="md">Click me</Button>
* <Button variant="secondary" size="lg" loading>Loading...</Button>
* <Button variant="ghost" size="sm">Cancel</Button>
* <Button variant="danger" size="md">Delete</Button>
* ```
*/
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
loading = false,
disabled,
children,
...props
},
ref
) => {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading ? (
<>
{/* EN: Loading spinner / VI: Spinner loading */}
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{children}
</>
) : (
children
)}
</button>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -1,121 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* EN: Card component - Container for grouping related content
* VI: Component Card - Container để nhóm các nội dung liên quan
*
* @param className - Additional CSS classes / Các class CSS bổ sung
* @param hover - Enable hover lift effect / Bật hiệu ứng nâng khi hover
* @param bordered - Show border / Hiển thị border
*/
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
hover?: boolean;
bordered?: boolean;
}
>(({ className, hover, bordered, ...props }, ref) => (
<div
ref={ref}
className={cn(
// EN: Base card styles / VI: Style cơ bản cho card
'bg-bg-secondary rounded-lg p-6 transition-all duration-[250ms] ease-out',
// EN: Hover effect - lift with shadow / VI: Hiệu ứng hover - nâng với shadow
hover && 'hover:-translate-y-0.5 hover:shadow-lg',
// EN: Border variant / VI: Biến thể có border
bordered && 'border border-border-primary',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
/**
* EN: CardHeader component - Header section of the card
* VI: Component CardHeader - Phần header của card
*/
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('mb-4', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
/**
* EN: CardTitle component - Title text in card header
* VI: Component CardTitle - Text tiêu đề trong card header
*/
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-xl font-semibold text-text-primary',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
/**
* EN: CardDescription component - Descriptive text in card header
* VI: Component CardDescription - Text mô tả trong card header
*/
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn(
'text-sm text-text-secondary mt-1',
className
)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
/**
* EN: CardContent component - Main content area of the card
* VI: Component CardContent - Khu vực nội dung chính của card
*/
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('', className)}
{...props}
/>
));
CardContent.displayName = 'CardContent';
/**
* EN: CardFooter component - Footer section of the card
* VI: Component CardFooter - Phần footer của card
*/
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('mt-4 flex items-center', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -1,186 +0,0 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* EN: Dialog component - Modal dialog using Radix UI
* VI: Component Dialog - Modal dialog sử dụng Radix UI
*/
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
/**
* EN: DialogOverlay component - Backdrop overlay with blur effect
* VI: Component DialogOverlay - Backdrop overlay với hiệu ứng blur
*/
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
// EN: Base overlay styles with backdrop blur / VI: Style overlay cơ bản với backdrop blur
'fixed inset-0 z-50 bg-black/60 backdrop-blur-[8px]',
// EN: Animation - fade in / VI: Animation - fade in
'data-[state=open]:opacity-100 data-[state=closed]:opacity-0',
'transition-opacity duration-normal ease-out',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/**
* EN: DialogContent component - Main dialog content container
* VI: Component DialogContent - Container nội dung dialog chính
*/
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
// EN: Base dialog styles / VI: Style dialog cơ bản
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg',
'bg-bg-elevated border border-border-primary rounded-2xl shadow-xl',
'p-6',
// EN: Animation - scale and fade / VI: Animation - scale và fade
'data-[state=open]:opacity-100 data-[state=closed]:opacity-0',
'data-[state=open]:scale-100 data-[state=closed]:scale-[0.96]',
'data-[state=closed]:translate-x-[-50%] data-[state=closed]:translate-y-[-48%]',
'data-[state=open]:translate-x-[-50%] data-[state=open]:translate-y-[-50%]',
'transition-all duration-[250ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close
className={cn(
// EN: Close button styles / VI: Style nút đóng
'absolute right-4 top-4 rounded-sm opacity-70',
'ring-offset-bg-elevated transition-opacity hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2',
'disabled:pointer-events-none',
// EN: Close icon / VI: Icon đóng
'text-text-tertiary hover:text-text-primary'
)}
>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
<span className="sr-only">Close / Đóng</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
/**
* EN: DialogHeader component - Header section of the dialog
* VI: Component DialogHeader - Phần header của dialog
*/
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left mb-4',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
/**
* EN: DialogFooter component - Footer section of the dialog
* VI: Component DialogFooter - Phần footer của dialog
*/
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
/**
* EN: DialogTitle component - Title text in dialog header
* VI: Component DialogTitle - Text tiêu đề trong dialog header
*/
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-xl font-semibold leading-none tracking-tight text-text-primary',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
/**
* EN: DialogDescription component - Descriptive text in dialog header
* VI: Component DialogDescription - Text mô tả trong dialog header
*/
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-text-secondary mt-1', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -1,285 +0,0 @@
'use client';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* EN: DropdownMenu component - Root component for dropdown menu
* VI: Component DropdownMenu - Component gốc cho dropdown menu
*/
const DropdownMenu = DropdownMenuPrimitive.Root;
/**
* EN: DropdownMenuTrigger component - Button that triggers the dropdown
* VI: Component DropdownMenuTrigger - Button kích hoạt dropdown
*/
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
/**
* EN: DropdownMenuGroup component - Group of menu items
* VI: Component DropdownMenuGroup - Nhóm các menu items
*/
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
/**
* EN: DropdownMenuPortal component - Portal for dropdown content
* VI: Component DropdownMenuPortal - Portal cho dropdown content
*/
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
/**
* EN: DropdownMenuSub component - Submenu root
* VI: Component DropdownMenuSub - Component gốc cho submenu
*/
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
/**
* EN: DropdownMenuRadioGroup component - Radio group for dropdown
* VI: Component DropdownMenuRadioGroup - Radio group cho dropdown
*/
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
/**
* EN: DropdownMenuSubTrigger component - Submenu trigger
* VI: Component DropdownMenuSubTrigger - Trigger cho submenu
*/
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-[#1A1A1A] data-[state=open]:bg-[#1A1A1A]',
inset && 'pl-8',
className
)}
{...props}
>
{children}
{/* EN: Chevron right icon for submenu / VI: Icon chevron phải cho submenu */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-auto h-4 w-4"
>
<path d="m9 18 6-6-6-6" />
</svg>
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
/**
* EN: DropdownMenuSubContent component - Submenu content
* VI: Component DropdownMenuSubContent - Nội dung submenu
*/
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-[#2A2A2A] bg-[#242424] p-1 text-[#FAFAFA] shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
/**
* EN: DropdownMenuContent component - Main dropdown content container
* VI: Component DropdownMenuContent - Container nội dung dropdown chính
*/
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
// EN: Base styles for dropdown content / VI: Styles cơ bản cho dropdown content
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-[#2A2A2A] bg-[#242424] p-1 text-[#FAFAFA] shadow-lg',
// EN: Animation styles / VI: Styles animation
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
/**
* EN: DropdownMenuItem component - Individual menu item
* VI: Component DropdownMenuItem - Item menu riêng lẻ
*/
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
// EN: Base menu item styles / VI: Styles cơ bản cho menu item
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
'focus:bg-[#1A1A1A] focus:text-[#FAFAFA]',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
/**
* EN: DropdownMenuCheckboxItem component - Checkbox menu item
* VI: Component DropdownMenuCheckboxItem - Menu item checkbox
*/
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-[#1A1A1A] focus:text-[#FAFAFA] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
{/* EN: Check icon / VI: Icon check */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
/**
* EN: DropdownMenuRadioItem component - Radio menu item
* VI: Component DropdownMenuRadioItem - Menu item radio
*/
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-[#1A1A1A] focus:text-[#FAFAFA] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
{/* EN: Circle icon for radio / VI: Icon hình tròn cho radio */}
<div className="h-2 w-2 rounded-full bg-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
/**
* EN: DropdownMenuLabel component - Label for menu group
* VI: Component DropdownMenuLabel - Label cho nhóm menu
*/
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold text-[#A0A0A0]', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
/**
* EN: DropdownMenuSeparator component - Separator line between menu items
* VI: Component DropdownMenuSeparator - Đường phân cách giữa các menu items
*/
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-[#2A2A2A]', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
/**
* EN: DropdownMenuShortcut component - Keyboard shortcut display
* VI: Component DropdownMenuShortcut - Hiển thị phím tắt
*/
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest text-[#A0A0A0]', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -1,201 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* EN: Input component props interface
* VI: Interface cho props của component Input
*/
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
/**
* EN: Validation state - affects styling / VI: Trạng thái validation - ảnh hưởng đến styling
*/
validationState?: 'default' | 'error' | 'success';
/**
* EN: Error message to display below input / VI: Thông báo lỗi hiển thị dưới input
*/
errorMessage?: string;
/**
* EN: Success message to display below input / VI: Thông báo thành công hiển thị dưới input
*/
successMessage?: string;
/**
* EN: Label text for the input / VI: Text label cho input
*/
label?: string;
/**
* EN: Helper text to display below input / VI: Text hướng dẫn hiển thị dưới input
*/
helperText?: string;
}
/**
* EN: Input component with validation states
* VI: Component Input với các trạng thái validation
*
* @example
* ```tsx
* <Input placeholder="Enter email" />
* <Input validationState="error" errorMessage="Invalid email" />
* <Input validationState="success" successMessage="Email is valid" />
* <Input disabled placeholder="Disabled input" />
* ```
*/
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
className,
type = 'text',
validationState = 'default',
errorMessage,
successMessage,
label,
helperText,
disabled,
id,
...props
},
ref
) => {
// EN: Generate unique ID if not provided / VI: Tạo ID duy nhất nếu không được cung cấp
const generatedId = React.useId();
const inputId = id || generatedId;
const labelId = `${inputId}-label`;
const helperId = `${inputId}-helper`;
const errorId = `${inputId}-error`;
const successId = `${inputId}-success`;
// EN: Determine validation state based on props / VI: Xác định trạng thái validation dựa trên props
const state =
errorMessage || validationState === 'error'
? 'error'
: successMessage || validationState === 'success'
? 'success'
: 'default';
return (
<div className="w-full">
{/* EN: Label / VI: Nhãn */}
{label && (
<label
htmlFor={inputId}
id={labelId}
className="block text-sm font-medium text-text-secondary mb-2"
>
{label}
</label>
)}
{/* EN: Input field / VI: Trường input */}
<input
type={type}
id={inputId}
ref={ref}
disabled={disabled}
className={cn(
// EN: Base styles / VI: Styles cơ bản
'flex w-full rounded-md border bg-bg-secondary px-3 py-2 text-base text-text-primary',
'placeholder:text-text-tertiary',
'transition-all duration-fast ease-out',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary',
// EN: Default state / VI: Trạng thái mặc định
state === 'default' &&
'border-border-primary focus-visible:border-accent-primary focus-visible:ring-accent-primary',
// EN: Focus state with glow effect / VI: Trạng thái focus với hiệu ứng glow
state === 'default' &&
!disabled &&
'focus-visible:shadow-[0_0_20px_rgba(59,130,246,0.3)]',
// EN: Error state / VI: Trạng thái lỗi
state === 'error' &&
'border-accent-error focus-visible:border-accent-error focus-visible:ring-accent-error',
// EN: Success state / VI: Trạng thái thành công
state === 'success' &&
'border-accent-success focus-visible:border-accent-success focus-visible:ring-accent-success',
// EN: Disabled state / VI: Trạng thái vô hiệu hóa
disabled &&
'cursor-not-allowed opacity-50 bg-bg-tertiary border-border-primary',
className
)}
aria-invalid={state === 'error' ? 'true' : 'false'}
aria-describedby={
errorMessage
? errorId
: successMessage
? successId
: helperText
? helperId
: undefined
}
{...props}
/>
{/* EN: Helper text / VI: Text hướng dẫn */}
{helperText && state === 'default' && !errorMessage && !successMessage && (
<p
id={helperId}
className="mt-1.5 text-sm text-text-tertiary"
role="note"
>
{helperText}
</p>
)}
{/* EN: Error message / VI: Thông báo lỗi */}
{errorMessage && (
<p
id={errorId}
className="mt-1.5 text-sm text-accent-error flex items-center gap-1"
role="alert"
>
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{errorMessage}
</p>
)}
{/* EN: Success message / VI: Thông báo thành công */}
{successMessage && (
<p
id={successId}
className="mt-1.5 text-sm text-accent-success flex items-center gap-1"
role="status"
>
<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>
{successMessage}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -1,181 +0,0 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* EN: Select component props interface
* VI: Interface cho props của component Select
*/
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
/**
* EN: Label text for the select / VI: Text label cho select
*/
label?: string;
/**
* EN: Helper text to display below select / VI: Text hướng dẫn hiển thị dưới select
*/
helperText?: string;
/**
* EN: Error message to display below select / VI: Thông báo lỗi hiển thị dưới select
*/
errorMessage?: string;
/**
* EN: Validation state - affects styling / VI: Trạng thái validation - ảnh hưởng đến styling
*/
validationState?: 'default' | 'error' | 'success';
}
/**
* EN: Select component with label and validation states
* VI: Component Select với label và các trạng thái validation
*
* @example
* ```tsx
* <Select label="Language" value={lang} onChange={(e) => setLang(e.target.value)}>
* <option value="en">English</option>
* <option value="vi">Vietnamese</option>
* </Select>
* ```
*/
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
(
{
className,
label,
helperText,
errorMessage,
validationState = 'default',
disabled,
id,
children,
...props
},
ref
) => {
// EN: Generate unique ID if not provided / VI: Tạo ID duy nhất nếu không được cung cấp
const generatedId = React.useId();
const selectId = id || generatedId;
const labelId = `${selectId}-label`;
const helperId = `${selectId}-helper`;
const errorId = `${selectId}-error`;
// EN: Determine validation state based on props / VI: Xác định trạng thái validation dựa trên props
const state = errorMessage || validationState === 'error' ? 'error' : validationState === 'success' ? 'success' : 'default';
return (
<div className="w-full">
{/* EN: Label / VI: Nhãn */}
{label && (
<label
htmlFor={selectId}
id={labelId}
className="block text-sm font-medium text-[#E0E0E0] mb-2"
>
{label}
</label>
)}
{/* EN: Select wrapper with icon / VI: Wrapper select với icon */}
<div className="relative">
<select
id={selectId}
ref={ref}
disabled={disabled}
className={cn(
// EN: Base styles / VI: Styles cơ bản
'flex w-full rounded-md border bg-[#121212] px-3 py-2 pr-10 text-base text-[#FAFAFA]',
'appearance-none',
'transition-all duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
// EN: Default state / VI: Trạng thái mặc định
state === 'default' &&
'border-[#2A2A2A] focus-visible:border-[#3B82F6] focus-visible:ring-[#3B82F6]',
// EN: Focus state with glow effect / VI: Trạng thái focus với hiệu ứng glow
state === 'default' &&
!disabled &&
'focus-visible:shadow-[0_0_20px_rgba(59,130,246,0.3)]',
// EN: Error state / VI: Trạng thái lỗi
state === 'error' &&
'border-[#EF4444] focus-visible:border-[#EF4444] focus-visible:ring-[#EF4444]',
// EN: Success state / VI: Trạng thái thành công
state === 'success' &&
'border-[#10B981] focus-visible:border-[#10B981] focus-visible:ring-[#10B981]',
// EN: Disabled state / VI: Trạng thái vô hiệu hóa
disabled && 'cursor-not-allowed opacity-50 bg-[#1A1A1A] border-[#2A2A2A]',
className
)}
aria-invalid={state === 'error' ? 'true' : 'false'}
aria-describedby={
errorMessage ? errorId : helperText ? helperId : undefined
}
{...props}
>
{children}
</select>
{/* EN: Dropdown arrow icon / VI: Icon mũi tên dropdown */}
<div
className={cn(
'pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3',
disabled && 'opacity-50'
)}
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-[#A0A0A0]"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</div>
{/* EN: Helper text / VI: Text hướng dẫn */}
{helperText && state === 'default' && !errorMessage && (
<p id={helperId} className="mt-1.5 text-sm text-[#A0A0A0]" role="note">
{helperText}
</p>
)}
{/* EN: Error message / VI: Thông báo lỗi */}
{errorMessage && (
<p
id={errorId}
className="mt-1.5 text-sm text-[#EF4444] flex items-center gap-1"
role="alert"
>
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{errorMessage}
</p>
)}
</div>
);
}
);
Select.displayName = 'Select';
export { Select };

View File

@@ -1,101 +0,0 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* EN: Switch component props interface
* VI: Interface cho props của component Switch
*/
export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
/**
* EN: Whether the switch is checked / VI: Switch có được bật hay không
*/
checked?: boolean;
/**
* EN: Default checked state (uncontrolled) / VI: Trạng thái checked mặc định (uncontrolled)
*/
defaultChecked?: boolean;
/**
* EN: Callback when checked state changes / VI: Callback khi trạng thái checked thay đổi
*/
onCheckedChange?: (checked: boolean) => void;
/**
* EN: Whether the switch is disabled / VI: Switch có bị vô hiệu hóa hay không
*/
disabled?: boolean;
}
/**
* EN: Switch component - Toggle switch for binary settings
* VI: Component Switch - Toggle switch cho các cài đặt nhị phân
*
* @example
* ```tsx
* <Switch checked={enabled} onCheckedChange={setEnabled} />
* <Switch defaultChecked disabled />
* ```
*/
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ className, checked, defaultChecked, onCheckedChange, disabled, ...props }, ref) => {
const [internalChecked, setInternalChecked] = React.useState(defaultChecked ?? false);
const isControlled = checked !== undefined;
const isChecked = isControlled ? checked : internalChecked;
const handleClick = () => {
if (disabled) return;
const newChecked = !isChecked;
if (!isControlled) {
setInternalChecked(newChecked);
}
onCheckedChange?.(newChecked);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
};
return (
<button
type="button"
role="switch"
aria-checked={isChecked}
ref={ref}
disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
// EN: Base switch styles / VI: Styles cơ bản cho switch
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent',
'transition-colors duration-150 ease-in-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3B82F6] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0A0A0A]',
'disabled:cursor-not-allowed disabled:opacity-50',
// EN: Checked state - blue background / VI: Trạng thái checked - nền xanh
isChecked ? 'bg-[#3B82F6]' : 'bg-[#374151]',
className
)}
{...props}
>
{/* EN: Switch thumb / VI: Thumb của switch */}
<span
className={cn(
// EN: Base thumb styles / VI: Styles cơ bản cho thumb
'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg',
'transform transition-transform duration-150 ease-in-out',
// EN: Checked state - translate to right / VI: Trạng thái checked - dịch sang phải
isChecked ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
);
}
);
Switch.displayName = 'Switch';
export { Switch };

View File

@@ -1,73 +0,0 @@
'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

@@ -1,142 +0,0 @@
'use client';
/**
* EN: i18n Context for managing locale state
* VI: Context i18n để quản lý trạng thái locale
*/
import * as React from 'react';
import { type Locale, defaultLocale, isValidLocale } from '@/i18n/config';
/**
* EN: i18n Context interface
* VI: Interface cho i18n Context
*/
interface I18nContextType {
/**
* EN: Current locale / VI: Locale hiện tại
*/
locale: Locale;
/**
* EN: Set locale function / VI: Hàm đặt locale
*/
setLocale: (locale: Locale) => void;
/**
* EN: Get locale function / VI: Hàm lấy locale
*/
getLocale: () => Locale;
}
/**
* EN: i18n Context
* VI: Context i18n
*/
const I18nContext = React.createContext<I18nContextType | undefined>(undefined);
/**
* EN: Get locale from localStorage or browser
* VI: Lấy locale từ localStorage hoặc browser
*/
function getStoredLocale(): Locale {
if (typeof window === 'undefined') {
return defaultLocale;
}
// EN: Try to get from localStorage preferences / VI: Thử lấy từ localStorage preferences
try {
const stored = localStorage.getItem('preferences');
if (stored) {
const parsed = JSON.parse(stored);
if (parsed.language && isValidLocale(parsed.language)) {
return parsed.language;
}
}
} catch {
// EN: Invalid stored data, continue to browser detection / VI: Dữ liệu lưu không hợp lệ, tiếp tục detect browser
}
// EN: Detect from browser language / VI: Phát hiện từ ngôn ngữ browser
if (typeof navigator !== 'undefined') {
const browserLang = navigator.language || navigator.languages?.[0] || '';
const langCode = browserLang.split('-')[0].toLowerCase();
if (isValidLocale(langCode)) {
return langCode;
}
}
return defaultLocale;
}
/**
* EN: I18n Provider component
* VI: Component I18n Provider
*/
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = React.useState<Locale>(() => getStoredLocale());
/**
* EN: Set locale and persist to localStorage
* VI: Đặt locale và lưu vào localStorage
*/
const setLocale = React.useCallback((newLocale: Locale) => {
if (!isValidLocale(newLocale)) {
console.warn(`Invalid locale: ${newLocale}`);
return;
}
setLocaleState(newLocale);
// EN: Update localStorage preferences / VI: Cập nhật localStorage preferences
if (typeof window !== 'undefined') {
try {
const stored = localStorage.getItem('preferences');
const preferences = stored ? JSON.parse(stored) : {};
preferences.language = newLocale;
localStorage.setItem('preferences', JSON.stringify(preferences));
} catch (error) {
console.error('Failed to save locale preference:', error);
}
// EN: Update HTML lang attribute / VI: Cập nhật thuộc tính lang của HTML
document.documentElement.lang = newLocale;
}
}, []);
/**
* EN: Get current locale
* VI: Lấy locale hiện tại
*/
const getLocale = React.useCallback(() => {
return locale;
}, [locale]);
// EN: Initialize HTML lang attribute on mount / VI: Khởi tạo thuộc tính lang của HTML khi mount
React.useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
}
}, [locale]);
const value = React.useMemo(
() => ({
locale,
setLocale,
getLocale,
}),
[locale, setLocale, getLocale]
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
/**
* EN: Hook to use i18n context
* VI: Hook để sử dụng i18n context
*/
export function useI18n() {
const context = React.useContext(I18nContext);
if (context === undefined) {
throw new Error('useI18n must be used within I18nProvider');
}
return context;
}

View File

@@ -1,164 +0,0 @@
'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

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

View File

@@ -1,24 +0,0 @@
/**
* EN: next-intl configuration
* VI: Cấu hình next-intl
*/
import { getRequestConfig } from 'next-intl/server';
import { defaultLocale, isValidLocale } from './i18n/config';
export default getRequestConfig(async ({ locale }) => {
// EN: Validate locale, fallback to default if invalid or undefined
// VI: Validate locale, fallback về default nếu không hợp lệ hoặc undefined
const validLocale = locale && isValidLocale(locale) ? locale : defaultLocale;
return {
locale: validLocale,
messages: (await import(`./src/i18n/messages/${validLocale}.json`)).default,
// EN: Enable fallback to default locale
// VI: Bật fallback về default locale
fallbackLocale: defaultLocale,
// EN: Disable environment fallback warnings in production
// VI: Tắt warnings environment fallback trong production
nowarn: process.env.NODE_ENV === 'production',
};
});

View File

@@ -1,30 +0,0 @@
/**
* EN: i18n configuration for next-intl
* VI: Cấu hình i18n cho next-intl
*/
/**
* EN: Supported locales
* VI: Các ngôn ngữ được hỗ trợ
*/
export const locales = ['en', 'vi'] as const;
/**
* EN: Default locale
* VI: Ngôn ngữ mặc định
*/
export const defaultLocale = 'en' as const;
/**
* EN: Locale type
* VI: Kiểu locale
*/
export type Locale = (typeof locales)[number];
/**
* EN: Check if a string is a valid locale
* VI: Kiểm tra xem một chuỗi có phải là locale hợp lệ không
*/
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale);
}

View File

@@ -1,250 +0,0 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"close": "Close",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"back": "Back",
"next": "Next",
"previous": "Previous",
"submit": "Submit",
"search": "Search",
"filter": "Filter",
"reset": "Reset",
"apply": "Apply",
"yes": "Yes",
"no": "No",
"ok": "OK",
"user": "User",
"optional": "Optional",
"export": "Export",
"download": "Download",
"selectAll": "Select All",
"selected": "selected",
"page": "Page",
"of": "of",
"rowsPerPage": "Rows per page",
"noData": "No data available",
"actions": "Actions",
"admin": "Admin",
"logout": "Logout",
"openSidebar": "Open sidebar",
"closeSidebar": "Close sidebar",
"adminSidebar": "Admin sidebar",
"showing": "Showing",
"to": "to",
"results": "results",
"copy": "Copy",
"view": "View",
"firstPage": "First page",
"lastPage": "Last page"
},
"auth": {
"login": {
"title": "Sign In",
"description": "Enter your credentials to access your account",
"email": "Email",
"password": "Password",
"rememberMe": "Remember me",
"forgotPassword": "Forgot password?",
"signUp": "Sign up",
"signingIn": "Signing in...",
"noAccount": "Don't have an account?",
"loginFailed": "Login failed",
"pageLabel": "Login page"
}
},
"admin": {
"dashboard": {
"title": "Dashboard",
"description": "Overview of your platform metrics and activities",
"totalUsers": "Total Users",
"messages": "Messages",
"activeUsers": "Active Users",
"revenue": "Revenue",
"userGrowth": "User Growth",
"revenueChart": "Revenue Chart",
"recentActivity": "Recent Activity",
"noActivity": "No recent activity"
},
"users": {
"title": "User Management",
"description": "Manage users, roles, and permissions",
"searchPlaceholder": "Search by name or email...",
"searchAndFilters": "Search & Filters",
"searchAndFiltersDesc": "Find and filter users by name, email, role, or status",
"filters": "Filters",
"activate": "Activate",
"deactivate": "Deactivate",
"role": "Role",
"status": "Status",
"email": "Email",
"name": "Name",
"createdAt": "Created",
"lastLogin": "Last Login",
"active": "Active",
"inactive": "Inactive",
"banned": "Banned",
"viewDetails": "View Details",
"editUser": "Edit User",
"deleteUser": "Delete User",
"banUser": "Ban User",
"unbanUser": "Unban User",
"deleteUser": "Are you sure you want to delete this user?",
"confirmDeleteUser": "Delete User"
},
"analytics": {
"title": "Analytics",
"overview": "Overview",
"users": "Users",
"messages": "Messages",
"performance": "Performance",
"keyMetrics": "Key Metrics",
"trends": "Trends",
"userAcquisition": "User Acquisition",
"retention": "Retention",
"cohortAnalysis": "Cohort Analysis",
"totalMessages": "Total Messages",
"avgPerUser": "Average per User",
"peakActivity": "Peak Activity Times",
"apiResponseTimes": "API Response Times",
"errorRates": "Error Rates",
"uptime": "Uptime Statistics"
},
"messages": {
"title": "Messages",
"description": "Manage and moderate messages",
"searchPlaceholder": "Search messages...",
"filterByStatus": "Filter by status",
"filterByType": "Filter by type",
"all": "All",
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"messageDetails": "Message Details",
"moderate": "Moderate",
"approve": "Approve",
"reject": "Reject",
"deleteMessage": "Delete Message",
"messageStats": "Message Statistics",
"total": "Total",
"today": "Today",
"thisWeek": "This Week",
"thisMonth": "This Month"
},
"settings": {
"title": "System Settings",
"description": "Configure system-wide settings and preferences",
"general": "General",
"email": "Email",
"security": "Security",
"api": "API",
"advanced": "Advanced",
"generalDesc": "Configure general system settings",
"emailDesc": "Configure email settings",
"securityDesc": "Configure security settings",
"apiDesc": "Configure API settings and webhooks",
"advancedDesc": "Advanced system configuration",
"siteName": "Site Name",
"siteNamePlaceholder": "Enter site name",
"siteLogo": "Site Logo",
"uploadLogo": "Upload Logo",
"logoRecommendation": "Recommended: 200x200px, PNG or SVG",
"defaultLanguage": "Default Language",
"interfaceLanguage": "Interface Language",
"interfaceLanguageDesc": "Choose your preferred language for the admin interface",
"timezone": "Timezone",
"timezoneHelper": "Server timezone",
"maintenanceMode": "Maintenance Mode",
"maintenanceModeDesc": "Enable maintenance mode to restrict access",
"saveSettings": "Save Settings",
"settingsSaved": "Settings saved successfully",
"settingsSaveFailed": "Failed to save settings",
"saving": "Saving...",
"smtpHost": "SMTP Host",
"smtpPort": "SMTP Port",
"smtpUser": "SMTP Username",
"smtpPassword": "SMTP Password",
"fromEmail": "From Email",
"fromName": "From Name",
"testEmail": "Test Email",
"sendTestEmail": "Send Test Email",
"testEmailSent": "Test email sent successfully",
"testEmailFailed": "Failed to send test email",
"passwordPolicy": "Password Policy",
"minLength": "Minimum Length",
"requireUppercase": "Require Uppercase",
"requireLowercase": "Require Lowercase",
"requireNumbers": "Require Numbers",
"requireSpecialChars": "Require Special Characters",
"sessionTimeout": "Session Timeout",
"apiKeys": "API Keys",
"createApiKey": "Create API Key",
"webhooks": "Webhooks",
"rateLimits": "Rate Limits",
"documentation": "Documentation",
"featureFlags": "Feature Flags",
"systemLogs": "System Logs",
"cacheManagement": "Cache Management",
"database": "Database",
"backup": "Backup",
"restore": "Restore"
},
"activity": {
"recentActivity": "Recent Activity",
"latestSystemActivities": "Latest system activities",
"noActivity": "No recent activity",
"status": {
"success": "Success",
"warning": "Warning",
"error": "Error",
"info": "Info",
"pending": "Pending"
},
"actions": {
"userCreated": "User Created",
"userUpdated": "User Updated",
"userDeleted": "User Deleted",
"userLogin": "User Login",
"userLogout": "User Logout",
"messageSent": "Message Sent",
"messageDeleted": "Message Deleted",
"settingsUpdated": "Settings Updated",
"systemBackup": "System Backup",
"systemRestore": "System Restore"
},
"justNow": "Just now",
"minutesAgo": "{minutes}m ago",
"hoursAgo": "{hours}h ago",
"daysAgo": "{days}d ago"
}
},
"validation": {
"required": "This field is required",
"email": "Invalid email format",
"emailRequired": "Email is required",
"password": "Password is required",
"passwordMin": "Password must be at least 8 characters",
"passwordConfirm": "Passwords do not match",
"passwordConfirmRequired": "Please confirm your password",
"minLength": "Must be at least {min} characters",
"maxLength": "Must be at most {max} characters",
"invalidFormat": "Invalid format",
"siteNameRequired": "Site name is required",
"languageRequired": "Language is required",
"timezoneRequired": "Timezone is required"
},
"errors": {
"generic": "An error occurred",
"networkError": "Network error. Please check your connection.",
"unauthorized": "You are not authorized to perform this action",
"notFound": "Resource not found",
"serverError": "Server error. Please try again later.",
"unknown": "An unknown error occurred"
}
}

View File

@@ -1,250 +0,0 @@
{
"common": {
"save": "Lưu",
"cancel": "Hủy",
"loading": "Đang tải...",
"error": "Lỗi",
"success": "Thành công",
"close": "Đóng",
"confirm": "Xác nhận",
"delete": "Xóa",
"edit": "Chỉnh sửa",
"back": "Quay lại",
"next": "Tiếp theo",
"previous": "Trước đó",
"submit": "Gửi",
"search": "Tìm kiếm",
"filter": "Lọc",
"reset": "Đặt lại",
"apply": "Áp dụng",
"yes": "Có",
"no": "Không",
"ok": "OK",
"user": "Người dùng",
"optional": "Tùy chọn",
"export": "Xuất",
"download": "Tải xuống",
"selectAll": "Chọn tất cả",
"selected": "đã chọn",
"page": "Trang",
"of": "của",
"rowsPerPage": "Số hàng mỗi trang",
"noData": "Không có dữ liệu",
"actions": "Hành động",
"admin": "Quản trị viên",
"logout": "Đăng xuất",
"openSidebar": "Mở sidebar",
"closeSidebar": "Đóng sidebar",
"adminSidebar": "Sidebar Admin",
"showing": "Hiển thị",
"to": "đến",
"results": "kết quả",
"copy": "Sao chép",
"view": "Xem",
"firstPage": "Trang đầu",
"lastPage": "Trang cuối"
},
"auth": {
"login": {
"title": "Đăng nhập",
"description": "Nhập thông tin đăng nhập để truy cập tài khoản",
"email": "Email",
"password": "Mật khẩu",
"rememberMe": "Nhớ đăng nhập",
"forgotPassword": "Quên mật khẩu?",
"signUp": "Đăng ký",
"signingIn": "Đang đăng nhập...",
"noAccount": "Chưa có tài khoản?",
"loginFailed": "Đăng nhập thất bại",
"pageLabel": "Trang đăng nhập"
}
},
"admin": {
"dashboard": {
"title": "Bảng điều khiển",
"description": "Tổng quan về các metric và hoạt động của nền tảng",
"totalUsers": "Tổng người dùng",
"messages": "Tin nhắn",
"activeUsers": "Người dùng hoạt động",
"revenue": "Doanh thu",
"userGrowth": "Tăng trưởng người dùng",
"revenueChart": "Biểu đồ doanh thu",
"recentActivity": "Hoạt động gần đây",
"noActivity": "Không có hoạt động gần đây"
},
"users": {
"title": "Quản lý người dùng",
"description": "Quản lý người dùng, vai trò và quyền",
"searchPlaceholder": "Tìm kiếm theo tên hoặc email...",
"searchAndFilters": "Tìm kiếm & Lọc",
"searchAndFiltersDesc": "Tìm và lọc người dùng theo tên, email, vai trò hoặc trạng thái",
"filters": "Lọc",
"activate": "Kích hoạt",
"deactivate": "Vô hiệu hóa",
"role": "Vai trò",
"status": "Trạng thái",
"email": "Email",
"name": "Tên",
"createdAt": "Đã tạo",
"lastLogin": "Đăng nhập cuối",
"active": "Hoạt động",
"inactive": "Không hoạt động",
"banned": "Bị cấm",
"viewDetails": "Xem chi tiết",
"editUser": "Chỉnh sửa người dùng",
"deleteUser": "Xóa người dùng",
"banUser": "Cấm người dùng",
"unbanUser": "Bỏ cấm người dùng",
"deleteUser": "Bạn có chắc chắn muốn xóa người dùng này?",
"confirmDeleteUser": "Xóa người dùng"
},
"analytics": {
"title": "Phân tích",
"overview": "Tổng quan",
"users": "Người dùng",
"messages": "Tin nhắn",
"performance": "Hiệu suất",
"keyMetrics": "Chỉ số chính",
"trends": "Xu hướng",
"userAcquisition": "Thu hút người dùng",
"retention": "Giữ chân",
"cohortAnalysis": "Phân tích cohort",
"totalMessages": "Tổng tin nhắn",
"avgPerUser": "Trung bình mỗi người dùng",
"peakActivity": "Thời gian hoạt động cao điểm",
"apiResponseTimes": "Thời gian phản hồi API",
"errorRates": "Tỷ lệ lỗi",
"uptime": "Thống kê thời gian hoạt động"
},
"messages": {
"title": "Tin nhắn",
"description": "Quản lý và kiểm duyệt tin nhắn",
"searchPlaceholder": "Tìm kiếm tin nhắn...",
"filterByStatus": "Lọc theo trạng thái",
"filterByType": "Lọc theo loại",
"all": "Tất cả",
"pending": "Đang chờ",
"approved": "Đã duyệt",
"rejected": "Đã từ chối",
"messageDetails": "Chi tiết tin nhắn",
"moderate": "Kiểm duyệt",
"approve": "Duyệt",
"reject": "Từ chối",
"deleteMessage": "Xóa tin nhắn",
"messageStats": "Thống kê tin nhắn",
"total": "Tổng",
"today": "Hôm nay",
"thisWeek": "Tuần này",
"thisMonth": "Tháng này"
},
"settings": {
"title": "Cài đặt hệ thống",
"description": "Cấu hình cài đặt và tùy chọn toàn hệ thống",
"general": "Chung",
"email": "Email",
"security": "Bảo mật",
"api": "API",
"advanced": "Nâng cao",
"generalDesc": "Cấu hình cài đặt hệ thống chung",
"emailDesc": "Cấu hình cài đặt email",
"securityDesc": "Cấu hình cài đặt bảo mật",
"apiDesc": "Cấu hình cài đặt API và webhooks",
"advancedDesc": "Cấu hình hệ thống nâng cao",
"siteName": "Tên site",
"siteNamePlaceholder": "Nhập tên site",
"siteLogo": "Logo site",
"uploadLogo": "Tải logo",
"logoRecommendation": "Khuyến nghị: 200x200px, PNG hoặc SVG",
"defaultLanguage": "Ngôn ngữ mặc định",
"interfaceLanguage": "Ngôn ngữ giao diện",
"interfaceLanguageDesc": "Chọn ngôn ngữ ưa thích cho giao diện quản trị",
"timezone": "Múi giờ",
"timezoneHelper": "Múi giờ server",
"maintenanceMode": "Chế độ bảo trì",
"maintenanceModeDesc": "Bật chế độ bảo trì để hạn chế truy cập",
"saveSettings": "Lưu cài đặt",
"settingsSaved": "Đã lưu cài đặt thành công",
"settingsSaveFailed": "Không thể lưu cài đặt",
"saving": "Đang lưu...",
"smtpHost": "SMTP Host",
"smtpPort": "SMTP Port",
"smtpUser": "SMTP Username",
"smtpPassword": "SMTP Password",
"fromEmail": "From Email",
"fromName": "From Name",
"testEmail": "Test Email",
"sendTestEmail": "Gửi Email Test",
"testEmailSent": "Đã gửi email test thành công",
"testEmailFailed": "Không thể gửi email test",
"passwordPolicy": "Chính sách mật khẩu",
"minLength": "Độ dài tối thiểu",
"requireUppercase": "Yêu cầu chữ hoa",
"requireLowercase": "Yêu cầu chữ thường",
"requireNumbers": "Yêu cầu số",
"requireSpecialChars": "Yêu cầu ký tự đặc biệt",
"sessionTimeout": "Thời gian hết hạn phiên",
"apiKeys": "Khóa API",
"createApiKey": "Tạo khóa API",
"webhooks": "Webhooks",
"rateLimits": "Giới hạn tốc độ",
"documentation": "Tài liệu",
"featureFlags": "Cờ tính năng",
"systemLogs": "Nhật ký hệ thống",
"cacheManagement": "Quản lý cache",
"database": "Cơ sở dữ liệu",
"backup": "Sao lưu",
"restore": "Khôi phục"
},
"activity": {
"recentActivity": "Hoạt động gần đây",
"latestSystemActivities": "Các hoạt động hệ thống mới nhất",
"noActivity": "Không có hoạt động gần đây",
"status": {
"success": "Thành công",
"warning": "Cảnh báo",
"error": "Lỗi",
"info": "Thông tin",
"pending": "Đang chờ"
},
"actions": {
"userCreated": "Tạo người dùng",
"userUpdated": "Cập nhật người dùng",
"userDeleted": "Xóa người dùng",
"userLogin": "Đăng nhập",
"userLogout": "Đăng xuất",
"messageSent": "Gửi tin nhắn",
"messageDeleted": "Xóa tin nhắn",
"settingsUpdated": "Cập nhật cài đặt",
"systemBackup": "Sao lưu hệ thống",
"systemRestore": "Khôi phục hệ thống"
},
"justNow": "Vừa xong",
"minutesAgo": "{minutes} phút trước",
"hoursAgo": "{hours} giờ trước",
"daysAgo": "{days} ngày trước"
}
},
"validation": {
"required": "Trường này là bắt buộc",
"email": "Định dạng email không hợp lệ",
"emailRequired": "Email là bắt buộc",
"password": "Mật khẩu là bắt buộc",
"passwordMin": "Mật khẩu phải có ít nhất 8 ký tự",
"passwordConfirm": "Mật khẩu không khớp",
"passwordConfirmRequired": "Vui lòng xác nhận mật khẩu",
"minLength": "Phải có ít nhất {min} ký tự",
"maxLength": "Phải có tối đa {max} ký tự",
"invalidFormat": "Định dạng không hợp lệ",
"siteNameRequired": "Tên site là bắt buộc",
"languageRequired": "Ngôn ngữ là bắt buộc",
"timezoneRequired": "Múi giờ là bắt buộc"
},
"errors": {
"generic": "Đã xảy ra lỗi",
"networkError": "Lỗi mạng. Vui lòng kiểm tra kết nối của bạn.",
"unauthorized": "Bạn không có quyền thực hiện hành động này",
"notFound": "Không tìm thấy tài nguyên",
"serverError": "Lỗi máy chủ. Vui lòng thử lại sau.",
"unknown": "Đã xảy ra lỗi không xác định"
}
}

View File

@@ -1,12 +0,0 @@
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

@@ -1,47 +0,0 @@
'use client';
/**
* EN: I18n Provider wrapper component
* VI: Component wrapper I18n Provider
*
* This component wraps the next-intl provider with our custom context
*/
import { NextIntlClientProvider } from 'next-intl';
import { I18nProvider as CustomI18nProvider } from '@/contexts/i18n-context';
import { useI18n } from '@/contexts/i18n-context';
import { useMemo } from 'react';
import enMessages from '@/i18n/messages/en.json';
import viMessages from '@/i18n/messages/vi.json';
/**
* EN: Inner provider that uses the locale from context
* VI: Provider bên trong sử dụng locale từ context
*/
function NextIntlProviderWrapper({ children }: { children: React.ReactNode }) {
const { locale } = useI18n();
// EN: Get messages based on locale - use static imports for immediate availability / VI: Lấy messages dựa trên locale - sử dụng static imports để có sẵn ngay
const messages = useMemo(() => {
return locale === 'vi' ? viMessages : enMessages;
}, [locale]);
// EN: Always render NextIntlClientProvider to ensure context exists / VI: Luôn render NextIntlClientProvider để đảm bảo context tồn tại
return (
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
);
}
/**
* EN: Main I18n Provider component
* VI: Component I18n Provider chính
*/
export function I18nProvider({ children }: { children: React.ReactNode }) {
return (
<CustomI18nProvider>
<NextIntlProviderWrapper>{children}</NextIntlProviderWrapper>
</CustomI18nProvider>
);
}

View File

@@ -1,45 +0,0 @@
import { apiClient } from './client';
import { LoginDto, RegisterDto, AuthResponse, ApiResponse, UserResponse } from '@goodgo/types';
export const authApi = {
register: async (data: RegisterDto): Promise<ApiResponse<AuthResponse>> => {
return apiClient.post('/auth/register', data);
},
login: async (data: LoginDto): Promise<ApiResponse<AuthResponse>> => {
const response = await apiClient.post('/auth/login', data);
if (response.success && response.data) {
apiClient.setAuthToken(response.data.accessToken);
if (typeof window !== 'undefined') {
localStorage.setItem('refreshToken', response.data.refreshToken);
}
}
return response;
},
logout: async (): Promise<ApiResponse> => {
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null;
const response = await apiClient.post('/auth/logout', { refreshToken });
apiClient.removeAuthToken();
if (typeof window !== 'undefined') {
localStorage.removeItem('refreshToken');
}
return response;
},
refreshToken: async (refreshToken: string): Promise<ApiResponse<{ accessToken: string }>> => {
const response = await apiClient.post('/auth/refresh', { refreshToken });
if (response.success && response.data) {
apiClient.setAuthToken(response.data.accessToken);
}
return response;
},
getMe: async (): Promise<ApiResponse<UserResponse>> => {
return apiClient.get('/users/me');
},
changePassword: async (currentPassword: string, newPassword: string): Promise<ApiResponse> => {
return apiClient.put('/auth/password', { currentPassword, newPassword });
},
};

View File

@@ -1,8 +0,0 @@
import { createHttpClient } from '@goodgo/http-client';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost/api/v1';
export const apiClient = createHttpClient({
baseURL: API_URL,
timeout: 30000,
});

View File

@@ -1,103 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { UserResponse } from '@goodgo/types';
import { authApi } from '../services/api/auth.api';
interface AuthState {
user: UserResponse | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, confirmPassword: string) => Promise<void>;
logout: () => Promise<void>;
fetchUser: () => Promise<void>;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
isLoading: false,
login: async (email: string, password: string) => {
set({ isLoading: true });
try {
const response = await authApi.login({ email, password });
if (response.success && response.data) {
set({
user: response.data.user,
isAuthenticated: true,
isLoading: false,
});
} else {
throw new Error(response.error?.message || 'Login failed');
}
} catch (error) {
set({ isLoading: false });
throw error;
}
},
register: async (email: string, password: string, confirmPassword: string) => {
set({ isLoading: true });
try {
const response = await authApi.register({ email, password, confirmPassword });
if (response.success && response.data) {
set({
user: response.data.user,
isAuthenticated: true,
isLoading: false,
});
} else {
throw new Error(response.error?.message || 'Registration failed');
}
} catch (error) {
set({ isLoading: false });
throw error;
}
},
logout: async () => {
try {
await authApi.logout();
} finally {
set({
user: null,
isAuthenticated: false,
});
}
},
fetchUser: async () => {
set({ isLoading: true });
try {
const response = await authApi.getMe();
if (response.success && response.data) {
set({
user: response.data,
isAuthenticated: true,
isLoading: false,
});
} else {
set({
user: null,
isAuthenticated: false,
isLoading: false,
});
}
} catch (error) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
});
}
},
}),
{
name: 'auth-storage',
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
}
)
);

View File

@@ -1,220 +0,0 @@
/**
* 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);
}

View File

@@ -1,155 +0,0 @@
/**
* EN: Tailwind CSS 4 Configuration for Web Admin
* VI: Cấu hình Tailwind CSS 4 cho Web Admin
*
* Note: Tailwind CSS 4 uses CSS-first configuration with @theme directive in CSS files.
* This config file extends the theme with additional utility classes based on CSS variables.
* The main theme tokens are defined in src/app/globals.css.
*
* Lưu ý: Tailwind CSS 4 sử dụng cấu hình CSS-first với @theme directive trong file CSS.
* File config này mở rộng theme với các utility classes bổ sung dựa trên CSS variables.
* Các theme tokens chính được định nghĩa trong src/app/globals.css.
*/
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
// EN: Colors from CSS variables (globals.css)
// VI: Màu sắc từ CSS variables (globals.css)
colors: {
bg: {
primary: 'var(--bg-primary)',
secondary: 'var(--bg-secondary)',
tertiary: 'var(--bg-tertiary)',
elevated: 'var(--bg-elevated)',
},
text: {
primary: 'var(--text-primary)',
secondary: 'var(--text-secondary)',
tertiary: 'var(--text-tertiary)',
inverse: 'var(--text-inverse)',
},
accent: {
primary: 'var(--accent-primary)',
secondary: 'var(--accent-secondary)',
success: 'var(--accent-success)',
warning: 'var(--accent-warning)',
error: 'var(--accent-error)',
info: 'var(--accent-info)',
},
border: {
primary: 'var(--border-primary)',
secondary: 'var(--border-secondary)',
focus: 'var(--border-focus)',
},
},
// EN: Font families from CSS variables
// VI: Font families từ CSS variables
fontFamily: {
sans: ['var(--font-sans)', 'sans-serif'],
mono: ['var(--font-mono)', 'monospace'],
},
// EN: Font sizes from CSS variables
// VI: Kích thước chữ từ CSS variables
fontSize: {
'6xl': ['var(--text-6xl)', { lineHeight: '1' }],
'5xl': ['var(--text-5xl)', { lineHeight: '1' }],
'4xl': ['var(--text-4xl)', { lineHeight: '1.1' }],
'3xl': ['var(--text-3xl)', { lineHeight: '1.2' }],
'2xl': ['var(--text-2xl)', { lineHeight: '1.3' }],
'xl': ['var(--text-xl)', { lineHeight: '1.4' }],
'lg': ['var(--text-lg)', { lineHeight: '1.5' }],
'base': ['var(--text-base)', { lineHeight: '1.5' }],
'sm': ['var(--text-sm)', { lineHeight: '1.5' }],
'xs': ['var(--text-xs)', { lineHeight: '1.5' }],
},
// EN: Font weights from CSS variables
// VI: Độ đậm chữ từ CSS variables
fontWeight: {
light: 'var(--font-light)',
normal: 'var(--font-normal)',
medium: 'var(--font-medium)',
semibold: 'var(--font-semibold)',
bold: 'var(--font-bold)',
},
// EN: Spacing from CSS variables
// VI: Khoảng cách từ CSS variables
spacing: {
'sidebar': 'var(--sidebar-width)',
'0': 'var(--space-0)',
'1': 'var(--space-1)',
'2': 'var(--space-2)',
'3': 'var(--space-3)',
'4': 'var(--space-4)',
'5': 'var(--space-5)',
'6': 'var(--space-6)',
'8': 'var(--space-8)',
'10': 'var(--space-10)',
'12': 'var(--space-12)',
'16': 'var(--space-16)',
'20': 'var(--space-20)',
},
// EN: Border radius from CSS variables
// VI: Bo góc từ CSS variables
borderRadius: {
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
'2xl': 'var(--radius-2xl)',
full: 'var(--radius-full)',
},
// EN: Box shadows from CSS variables
// VI: Đổ bóng từ CSS variables
boxShadow: {
sm: 'var(--shadow-sm)',
md: 'var(--shadow-md)',
lg: 'var(--shadow-lg)',
xl: 'var(--shadow-xl)',
glow: 'var(--shadow-glow)',
},
// EN: Animation timing functions
// VI: Hàm thời gian animation
transitionTimingFunction: {
'in': 'var(--ease-in)',
'out': 'var(--ease-out)',
'in-out': 'var(--ease-in-out)',
spring: 'var(--ease-spring)',
},
// EN: Animation durations
// VI: Thời lượng animation
transitionDuration: {
fast: 'var(--duration-fast)',
normal: 'var(--duration-normal)',
slow: 'var(--duration-slow)',
slower: 'var(--duration-slower)',
},
// EN: Max widths for containers
// VI: Chiều rộng tối đa cho containers
maxWidth: {
'container-sm': 'var(--container-sm)',
'container-md': 'var(--container-md)',
'container-lg': 'var(--container-lg)',
'container-xl': 'var(--container-xl)',
'container-2xl': 'var(--container-2xl)',
},
// EN: Screen breakpoints
// VI: Điểm ngắt màn hình
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
},
},
},
plugins: [],
};

View File

@@ -1,21 +0,0 @@
{
"extends": "@goodgo/tsconfig/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"isolatedModules": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -14,10 +14,10 @@
"dependencies": {
"@goodgo/http-client": "workspace:*",
"@goodgo/types": "workspace:*",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -26,21 +26,21 @@
"@react-stately/overlays": "^3.6.21",
"@react-stately/select": "^3.9.0",
"@react-stately/toggle": "^3.9.3",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^12.23.26",
"@tanstack/react-query": "^5.90.16",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.24.8",
"lucide-react": "^0.562.0",
"next": "^14.1.0",
"next": "^16.1.1",
"next-intl": "^4.7.0",
"react": "^18.2.0",
"react": "^19.2.3",
"react-aria": "^3.45.0",
"react-aria-components": "^1.14.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.70.0",
"react-stately": "^3.43.0",
"zod": "^3.22.4",
"zod": "^4.3.5",
"zustand": "^5.0.9"
},
"devDependencies": {
@@ -55,22 +55,22 @@
"@storybook/addon-onboarding": "^10.1.11",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/nextjs-vite": "^10.1.11",
"@tailwindcss/postcss": "^4.0.0",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.0.3",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitest/browser-playwright": "^4.0.16",
"@vitest/coverage-v8": "^4.0.16",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-next": "^14.1.0",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.1",
"eslint-plugin-storybook": "^10.1.11",
"playwright": "^1.57.0",
"postcss": "^8.4.33",
"postcss": "^8.5.6",
"storybook": "^10.1.11",
"tailwindcss": "^4.0.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.0",
"vite": "^7.3.1",
"vitest": "^4.0.16"
},
"eslintConfig": {

View File

@@ -33,9 +33,9 @@
},
"devDependencies": {
"@types/node": "^25.0.3",
"eslint": "^8.56.0",
"eslint": "^9.39.2",
"prettier": "^3.7.4",
"turbo": "^1.11.2",
"turbo": "^2.7.3",
"typescript": "^5.9.3"
},
"engines": {

View File

@@ -11,11 +11,11 @@
},
"dependencies": {
"@goodgo/types": "workspace:*",
"jsonwebtoken": "^9.0.2"
"jsonwebtoken": "^9.0.3"
},
"devDependencies": {
"@goodgo/tsconfig": "workspace:*",
"@types/jsonwebtoken": "^9.0.5",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.0.3",
"typescript": "^5.9.3"
}

View File

@@ -4,10 +4,10 @@
"description": "Shared ESLint configuration",
"main": "index.js",
"dependencies": {
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1"
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0"
}
}

View File

@@ -4,6 +4,6 @@
"description": "Shared Prettier configuration",
"main": "index.js",
"dependencies": {
"prettier": "^3.2.4"
"prettier": "^3.7.4"
}
}

View File

@@ -11,7 +11,7 @@
},
"dependencies": {
"@goodgo/types": "workspace:*",
"axios": "^1.6.5"
"axios": "^1.13.2"
},
"devDependencies": {
"@goodgo/tsconfig": "workspace:*",

View File

@@ -10,7 +10,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"winston": "^3.11.0"
"winston": "^3.19.0"
},
"devDependencies": {
"@goodgo/tsconfig": "workspace:*",

5462
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,37 +30,37 @@
"@prisma/client": "^7.2.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"ioredis": "^5.9.0",
"opossum": "^9.0.0",
"prom-client": "^15.1.3",
"rate-limit-redis": "^4.3.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"zod": "^3.22.4"
"zod": "^4.3.5"
},
"devDependencies": {
"@goodgo/eslint-config": "workspace:*",
"@goodgo/tsconfig": "workspace:*",
"@jest/globals": "^29.7.0",
"@types/cors": "^2.8.17",
"@jest/globals": "^30.2.0",
"@types/cors": "^2.8.19",
"@types/dotenv": "^8.2.3",
"@types/express": "^4.17.21",
"@types/express": "^5.0.6",
"@types/ioredis": "^5.0.0",
"@types/jest": "^29.5.11",
"@types/jest": "^30.0.0",
"@types/node": "^25.0.3",
"@types/opossum": "^8.1.9",
"@types/supertest": "^6.0.2",
"@types/swagger-jsdoc": "^6.0.1",
"@types/swagger-ui-express": "^4.1.6",
"jest": "^29.7.0",
"@types/supertest": "^6.0.3",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"jest": "^30.2.0",
"prisma": "^7.2.0",
"supertest": "^7.0.0",
"ts-jest": "^29.1.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tsx": "^4.7.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View File

@@ -16,7 +16,7 @@ dotenv.config({ path: path.resolve(process.cwd(), '.env.local'), override: true
* VI: Schema biến môi trường
*/
const envSchema = z.object({
PORT: z.string().transform(Number).default('5000'),
PORT: z.string().default('5000').transform(Number), // Reorder: default before transform
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
API_VERSION: z.string().default('v1'),
CORS_ORIGIN: z.string().optional().default('http://localhost:3000'),

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
import { z, ZodError } from 'zod';
import { logger } from '@goodgo/logger';
/**
@@ -9,11 +9,11 @@ import { logger } from '@goodgo/logger';
* @param schema - Zod schema to validate against / Schema Zod để validate
* @param property - Request property to validate ('body', 'query', 'params') / Property request để validate
*/
export const validateDto = (schema: AnyZodObject, property: 'body' | 'query' | 'params' = 'body') => {
export const validateDto = (schema: z.ZodTypeAny, property: 'body' | 'query' | 'params' = 'body') => {
return (req: Request, res: Response, next: NextFunction) => {
try {
// EN: Sanitize input by trimming strings
// VI: Sanitize input bằng cách trim strings
// VI: Sanitize input bằngcách trim strings
const sanitizedData = sanitizeInput(req[property]);
// EN: Validate the sanitized data
@@ -33,7 +33,7 @@ export const validateDto = (schema: AnyZodObject, property: 'body' | 'query' | '
if (error instanceof ZodError) {
logger.warn('Request validation failed / Validation request thất bại', {
property,
errors: error.errors,
errors: error.issues, // Zod 4: error.errors → error.issues
body: req.body,
});
@@ -44,7 +44,7 @@ export const validateDto = (schema: AnyZodObject, property: 'body' | 'query' | '
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request data / Dữ liệu request không hợp lệ',
details: error.errors.map(err => ({
details: error.issues.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,

View File

@@ -8,7 +8,7 @@ export const createFeatureDtoSchema = z.object({
name: z.string().min(1, 'Name is required / Tên là bắt buộc').max(100, 'Name must be less than 100 characters / Tên phải ít hơn 100 ký tự'),
title: z.string().max(200, 'Title must be less than 200 characters / Tiêu đề phải ít hơn 200 ký tự').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự').optional(),
config: z.record(z.any()).optional(),
config: z.record(z.string(), z.any()).optional(),
tags: z.array(z.string()).optional(),
});
@@ -21,7 +21,7 @@ export type CreateFeatureDto = z.infer<typeof createFeatureDtoSchema>;
export const updateFeatureDtoSchema = z.object({
title: z.string().max(200, 'Title must be less than 200 characters / Tiêu đề phải ít hơn 200 ký tự').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự').optional(),
config: z.record(z.any()).optional(),
config: z.record(z.string(), z.any()).optional(),
enabled: z.boolean().optional(),
tags: z.array(z.string()).optional(),
});

View File

@@ -30,69 +30,69 @@
"@neondatabase/serverless": "^1.0.2",
"@prisma/adapter-neon": "^7.2.0",
"@prisma/client": "^7.2.0",
"@simplewebauthn/server": "^9.0.0",
"@simplewebauthn/server": "^13.2.2",
"@types/dompurify": "^3.2.0",
"@types/jsdom": "^27.0.0",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dompurify": "^3.3.1",
"dotenv": "^17.2.3",
"express": "^4.22.1",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"ioredis": "^5.9.0",
"jsdom": "^27.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.3",
"node-cache": "^5.1.2",
"oidc-provider": "^8.0.0",
"openid-client": "^5.6.5",
"oidc-provider": "^9.6.0",
"openid-client": "^6.8.1",
"opossum": "^9.0.0",
"passport": "^0.7.0",
"passport-facebook": "^3.0.0",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"prom-client": "^15.1.3",
"qrcode": "^1.5.3",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.3.1",
"speakeasy": "^2.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^13.0.0",
"zod": "^3.22.4"
"zod": "^4.3.5"
},
"devDependencies": {
"@faker-js/faker": "^10.1.0",
"@faker-js/faker": "^10.2.0",
"@goodgo/eslint-config": "workspace:*",
"@goodgo/tsconfig": "workspace:*",
"@jest/globals": "^29.7.0",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.6",
"@types/cors": "^2.8.17",
"@jest/globals": "^30.2.0",
"@types/bcryptjs": "^3.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/dotenv": "^8.2.3",
"@types/express": "^4.17.21",
"@types/express": "^5.0.6",
"@types/ioredis": "^5.0.0",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.0.3",
"@types/node-cache": "^4.2.5",
"@types/opossum": "^8.1.9",
"@types/passport": "^1.0.16",
"@types/passport": "^1.0.17",
"@types/passport-facebook": "^3.0.4",
"@types/passport-github2": "^1.2.9",
"@types/passport-google-oauth20": "^2.0.14",
"@types/qrcode": "^1.5.5",
"@types/passport-google-oauth20": "^2.0.17",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/supertest": "^6.0.2",
"@types/swagger-jsdoc": "^6.0.1",
"@types/swagger-ui-express": "^4.1.6",
"@types/uuid": "^9.0.7",
"jest": "^29.7.0",
"@types/supertest": "^6.0.3",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^11.0.0",
"jest": "^30.2.0",
"prisma": "^7.2.0",
"supertest": "^7.0.0",
"ts-jest": "^29.1.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tsx": "^4.7.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
"pnpm": {

View File

@@ -17,7 +17,7 @@ dotenv.config({ path: path.resolve(process.cwd(), '.env.local'), override: true
* VI: Schema biến môi trường
*/
const envSchema = z.object({
PORT: z.string().transform(Number).default('5000'),
PORT: z.string().default('5000').transform(Number), // Reorder: default before transform
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
API_VERSION: z.string().default('v1'),
CORS_ORIGIN: z.string().optional().default('http://localhost:3000'),

View File

@@ -1,6 +1,6 @@
import { logger } from '@goodgo/logger';
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
import { z, ZodError } from 'zod';
/**
* EN: Middleware to validate request data using Zod schemas
@@ -9,7 +9,7 @@ import { AnyZodObject, ZodError } from 'zod';
* @param schema - Zod schema to validate against / Schema Zod để validate
* @param property - Request property to validate ('body', 'query', 'params') / Property request để validate
*/
export const validateDto = (schema: AnyZodObject, property: 'body' | 'query' | 'params' = 'body') => {
export const validateDto = (schema: z.ZodTypeAny, property: 'body' | 'query' | 'params' = 'body') => {
return (req: Request, res: Response, next: NextFunction) => {
try {
// EN: Sanitize input by trimming strings
@@ -33,7 +33,7 @@ export const validateDto = (schema: AnyZodObject, property: 'body' | 'query' | '
if (error instanceof ZodError) {
logger.warn('Request validation failed / Validation request thất bại', {
property,
errors: error.errors,
errors: error.issues, // Zod 4: error.errors → error.issues
body: req.body,
});
@@ -44,7 +44,7 @@ export const validateDto = (schema: AnyZodObject, property: 'body' | 'query' | '
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request data / Dữ liệu request không hợp lệ',
details: error.errors.map(err => ({
details: error.issues.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,

View File

@@ -14,7 +14,7 @@ export const CreateAccessRequestDto = z.object({
action: z.string().min(1, 'Action is required / Action là bắt buộc'),
reason: z.string().max(1000).optional(),
expiresAt: z.string().datetime().optional(),
metadata: z.record(z.any()).optional(),
metadata: z.record(z.string(), z.any()).optional(),
});
export type CreateAccessRequestDto = z.infer<typeof CreateAccessRequestDto>;

View File

@@ -35,7 +35,7 @@ export class AccessAnalyticsController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid date range',
details: error.errors,
details: error.issues,
},
});
return;
@@ -122,7 +122,7 @@ export class AccessAnalyticsController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid date range',
details: error.errors,
details: error.issues,
},
});
return;
@@ -164,7 +164,7 @@ export class AccessAnalyticsController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid filters',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -81,7 +81,7 @@ export class AccessRequestController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -160,7 +160,7 @@ export class AccessRequestController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -215,7 +215,7 @@ export class AccessRequestController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -69,7 +69,7 @@ export class AccessReviewController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -237,7 +237,7 @@ export class AccessReviewController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -43,7 +43,7 @@ export class AuthController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -100,7 +100,7 @@ export class AuthController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -46,7 +46,7 @@ export class ChangePasswordController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -8,7 +8,7 @@ export const createFeatureDtoSchema = z.object({
name: z.string().min(1, 'Name is required / Tên là bắt buộc').max(100, 'Name must be less than 100 characters / Tên phải ít hơn 100 ký tự'),
title: z.string().max(200, 'Title must be less than 200 characters / Tiêu đề phải ít hơn 200 ký tự').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự').optional(),
config: z.record(z.any()).optional(),
config: z.record(z.string(), z.any()).optional(),
tags: z.array(z.string()).optional(),
});
@@ -21,7 +21,7 @@ export type CreateFeatureDto = z.infer<typeof createFeatureDtoSchema>;
export const updateFeatureDtoSchema = z.object({
title: z.string().max(200, 'Title must be less than 200 characters / Tiêu đề phải ít hơn 200 ký tự').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự').optional(),
config: z.record(z.any()).optional(),
config: z.record(z.string(), z.any()).optional(),
enabled: z.boolean().optional(),
tags: z.array(z.string()).optional(),
});

View File

@@ -47,7 +47,7 @@ export class ComplianceController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid filters',
details: error.errors,
details: error.issues,
},
});
return;
@@ -105,7 +105,7 @@ export class ComplianceController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -48,7 +48,7 @@ export type CreatePolicyTemplateDto = z.infer<typeof CreatePolicyTemplateDto>;
export const UpdatePolicyTemplateDto = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional(),
content: z.record(z.any()).optional(),
content: z.record(z.string(), z.any()).optional(),
version: z.string().optional(),
isActive: z.boolean().optional(),
});
@@ -84,7 +84,7 @@ export type TestPolicyDto = z.infer<typeof TestPolicyDto>;
export const CalculateRiskScoreDto = z.object({
userId: z.string().min(1, 'User ID is required / User ID là bắt buộc'),
factors: z.record(z.any()).optional(),
factors: z.record(z.string(), z.any()).optional(),
});
export type CalculateRiskScoreDto = z.infer<typeof CalculateRiskScoreDto>;

View File

@@ -57,7 +57,7 @@ export class PolicyGovernanceController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -123,7 +123,7 @@ export class PolicyGovernanceController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -36,7 +36,7 @@ export class ReportingController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid filters',
details: error.errors,
details: error.issues,
},
});
return;
@@ -78,7 +78,7 @@ export class ReportingController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid filters',
details: error.errors,
details: error.issues,
},
});
return;
@@ -129,7 +129,7 @@ export class ReportingController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid filters',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -49,7 +49,7 @@ export class RiskController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid filters',
details: error.errors,
details: error.issues,
},
});
return;
@@ -115,7 +115,7 @@ export class RiskController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -58,7 +58,7 @@ export class GroupController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -130,7 +130,7 @@ export class GroupController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -227,7 +227,7 @@ export class GroupController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -13,7 +13,7 @@ export const CreateOrganizationDto = z.object({
name: z.string().min(1, 'Organization name is required / Tên tổ chức là bắt buộc').max(255),
domain: z.string().email().optional().or(z.string().min(1).max(255).optional()),
parentId: z.string().optional(),
settings: z.record(z.any()).optional(),
settings: z.record(z.string(), z.any()).optional(),
});
export type CreateOrganizationDto = z.infer<typeof CreateOrganizationDto>;
@@ -22,7 +22,7 @@ export const UpdateOrganizationDto = z.object({
name: z.string().min(1).max(255).optional(),
domain: z.string().email().optional().or(z.string().min(1).max(255).optional()),
parentId: z.string().nullable().optional(),
settings: z.record(z.any()).optional(),
settings: z.record(z.string(), z.any()).optional(),
isActive: z.boolean().optional(),
});
@@ -65,9 +65,9 @@ export const CreateUserProfileDto = z.object({
lastName: z.string().max(255).optional(),
phone: z.string().max(20).optional(),
avatarUrl: z.string().url().optional(),
customFields: z.record(z.any()).optional(),
preferences: z.record(z.any()).optional(),
metadata: z.record(z.any()).optional(),
customFields: z.record(z.string(), z.any()).optional(),
preferences: z.record(z.string(), z.any()).optional(),
metadata: z.record(z.string(), z.any()).optional(),
});
export type CreateUserProfileDto = z.infer<typeof CreateUserProfileDto>;
@@ -77,9 +77,9 @@ export const UpdateUserProfileDto = z.object({
lastName: z.string().max(255).optional(),
phone: z.string().max(20).optional(),
avatarUrl: z.string().url().optional(),
customFields: z.record(z.any()).optional(),
preferences: z.record(z.any()).optional(),
metadata: z.record(z.any()).optional(),
customFields: z.record(z.string(), z.any()).optional(),
preferences: z.record(z.string(), z.any()).optional(),
metadata: z.record(z.string(), z.any()).optional(),
});
export type UpdateUserProfileDto = z.infer<typeof UpdateUserProfileDto>;
@@ -91,7 +91,7 @@ export type UpdateUserProfileDto = z.infer<typeof UpdateUserProfileDto>;
export const VerificationRequestDto = z.object({
type: z.enum(['EMAIL', 'PHONE', 'DOCUMENT', 'BIOMETRIC']),
method: z.string().optional(),
metadata: z.record(z.any()).optional(),
metadata: z.record(z.string(), z.any()).optional(),
});
export type VerificationRequestDto = z.infer<typeof VerificationRequestDto>;

View File

@@ -66,7 +66,7 @@ export class OrganizationController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -138,7 +138,7 @@ export class OrganizationController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -69,7 +69,7 @@ export class ProfileController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -47,7 +47,7 @@ export class UserManagementController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid filters',
details: error.errors,
details: error.issues,
},
});
return;
@@ -114,7 +114,7 @@ export class UserManagementController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -245,7 +245,7 @@ export class UserManagementController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -292,7 +292,7 @@ export class UserManagementController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid filters',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -74,7 +74,7 @@ export class VerificationController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -170,7 +170,7 @@ export class VerificationController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -88,7 +88,7 @@ export class MFAController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -141,7 +141,7 @@ export class MFAController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;

View File

@@ -83,7 +83,7 @@ export class RBACController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;
@@ -157,7 +157,7 @@ export class RBACController {
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors,
details: error.issues,
},
});
return;