diff --git a/.cursor/plans/điều_chỉnh_giao_diện_đa_ngôn_ngữ_cho_web-client_a7794ae1.plan.md b/.cursor/plans/điều_chỉnh_giao_diện_đa_ngôn_ngữ_cho_web-client_a7794ae1.plan.md new file mode 100644 index 00000000..f3948224 --- /dev/null +++ b/.cursor/plans/điều_chỉnh_giao_diện_đa_ngôn_ngữ_cho_web-client_a7794ae1.plan.md @@ -0,0 +1,446 @@ +--- +name: Điều chỉnh giao diện đa ngôn ngữ cho web-client +overview: Thiết lập hệ thống i18n hoàn chỉnh với next-intl, migrate tất cả hardcoded text sang translation files, và tích hợp với language preferences để hiển thị đúng ngôn ngữ người dùng chọn. +todos: + - id: install-deps + content: Cài đặt next-intl package - Update package.json và chạy pnpm install + status: completed + - id: create-i18n-config + content: Tạo src/i18n/config.ts - Định nghĩa locales ['en', 'vi'], default 'en' + status: completed + - id: create-i18n-request + content: Tạo src/i18n/request.ts - Request handler với locale detection logic + status: completed + - id: create-i18n-context + content: Tạo src/contexts/i18n-context.tsx - Context với setLocale, getLocale, localStorage sync + status: completed + - id: create-i18n-provider + content: Tạo src/providers/i18n-provider.tsx - Provider component với browser detection + status: completed + - id: create-translation-hook + content: Tạo src/hooks/use-translation.ts - Hook useTranslation() với type safety + status: completed + - id: update-root-layout + content: Update src/app/layout.tsx - Wrap với I18nProvider, dynamic lang attribute + status: completed + - id: extract-common-translations + content: Tạo translation keys cho common namespace - buttons, labels, placeholders (en.json + vi.json) + status: completed + - id: extract-auth-translations + content: Tạo translation keys cho auth namespace - login, register, forgot-password (en.json + vi.json) + status: completed + - id: extract-chat-translations + content: Tạo translation keys cho chat namespace - messages, sidebar, input (en.json + vi.json) + status: completed + - id: extract-settings-translations + content: Tạo translation keys cho settings namespace - preferences, profile, security, api-keys (en.json + vi.json) + status: completed + - id: extract-validation-translations + content: Tạo translation keys cho validation namespace - form errors, Zod messages (en.json + vi.json) + status: completed + - id: extract-error-translations + content: Tạo translation keys cho errors namespace - API errors, general errors (en.json + vi.json) + status: completed + - id: migrate-login-page + content: Migrate src/app/(auth)/login/page.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-register-page + content: Migrate src/app/(auth)/register/page.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-forgot-password-page + content: Migrate src/app/(auth)/forgot-password/page.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-login-validation + content: Update Zod schema trong login page - Sử dụng translated validation messages + status: completed + - id: migrate-register-validation + content: Update Zod schema trong register page - Sử dụng translated validation messages + status: completed + - id: migrate-forgot-password-validation + content: Update Zod schema trong forgot-password page - Sử dụng translated validation messages + status: completed + - id: migrate-home-page + content: Migrate src/app/page.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-chat-page + content: Migrate src/app/(dashboard)/chat/page.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-settings-layout + content: Migrate src/app/(dashboard)/settings/layout.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-preferences-page + content: Migrate src/app/(dashboard)/settings/preferences/page.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-profile-page + content: Migrate src/app/(dashboard)/settings/profile/page.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-security-page + content: Migrate src/app/(dashboard)/settings/security/page.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-api-keys-page + content: Migrate src/app/(dashboard)/settings/api-keys/page.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-chat-input + content: Migrate src/components/chat/chat-input.tsx - Replace hardcoded text với useTranslation() + status: completed + - id: migrate-conversation-sidebar + content: Migrate src/components/chat/conversation-sidebar.tsx - Replace hardcoded text, format dates với useTranslation() + status: completed + - id: migrate-message-bubble + content: Migrate src/components/chat/message-bubble.tsx - Replace hardcoded text với useTranslation() (nếu có) + status: completed + - id: migrate-chat-layout + content: Migrate src/components/chat/chat-layout.tsx - Replace hardcoded text với useTranslation() (nếu có) + status: completed + - id: migrate-message-actions-menu + content: Migrate src/components/chat/message-actions-menu.tsx - Replace hardcoded text với useTranslation() (nếu có) + status: completed + - id: migrate-typing-indicator + content: Migrate src/components/chat/typing-indicator.tsx - Replace hardcoded text với useTranslation() (nếu có) + status: completed + - id: check-ui-components + content: Review src/components/ui/*.tsx - Kiểm tra và migrate nếu có hardcoded text + status: completed + - id: migrate-chat-store + content: Review src/stores/chat-store.ts - Migrate error messages, status messages nếu có + status: completed + - id: migrate-auth-store + content: Review src/stores/auth-store.ts - Migrate error messages, status messages nếu có + status: completed + - id: integrate-preferences-language-switch + content: Update preferences page - Kết nối language selector với i18n context, trigger re-render + status: completed + - id: add-date-formatters + content: Implement date formatting - Sử dụng next-intl formatters cho relative time trong sidebar + status: completed + - id: add-number-formatters + content: Implement number formatting - Sử dụng next-intl formatters cho numbers (nếu cần) + status: completed + - id: test-language-switching + content: Test language switching - Verify UI updates khi đổi ngôn ngữ trong preferences + status: completed + - id: test-browser-detection + content: Test browser language detection - Verify auto-detect từ navigator.language + status: completed + - id: test-persistence + content: Test persistence - Verify language preference lưu trong localStorage và persist qua reload + status: completed + - id: test-all-pages-en + content: Test tất cả pages với English - Verify không có missing translations, UI hiển thị đúng + status: completed + - id: test-all-pages-vi + content: Test tất cả pages với Vietnamese - Verify không có missing translations, UI hiển thị đúng + status: completed + - id: verify-html-lang-attribute + content: Verify HTML lang attribute - Check updates đúng khi đổi ngôn ngữ + status: completed + - id: test-validation-messages + content: Test validation messages - Verify form validation hiển thị đúng ngôn ngữ + status: completed +--- + +# Plan: Điều chỉnh giao diện đa ngôn ngữ cho web-client + +## Tổng quan + +Hiện tại web-client đang sử dụng format hardcode song ngữ "English / Tiếng Việt" cho tất cả text. Plan này sẽ: + +- Setup next-intl cho Next.js App Router +- Tạo translation files cho en và vi +- Migrate tất cả hardcoded text sang translation system +- Tích hợp với language preferences từ localStorage +- Detect browser language cho user mới +- Update HTML lang attribute động + +## Kiến trúc + +``` +apps/web-client/ +├── src/ +│ ├── i18n/ +│ │ ├── config.ts # Cấu hình next-intl +│ │ ├── request.ts # Request handler cho SSR +│ │ └── messages/ +│ │ ├── en.json # English translations +│ │ └── vi.json # Vietnamese translations +│ ├── contexts/ +│ │ └── i18n-context.tsx # Client-side i18n context +│ ├── hooks/ +│ │ └── use-translation.ts # Custom hook cho translations +│ └── providers/ +│ └── i18n-provider.tsx # I18n provider component +``` + +## Các bước thực hiện + +### 1. Cài đặt dependencies + +- Thêm `next-intl` vào `package.json` +- Cài đặt package: `pnpm add next-intl` + +### 2. Tạo cấu trúc i18n + +**File: `src/i18n/config.ts`** + +- Định nghĩa supported locales: `['en', 'vi']` +- Default locale: `'en'` +- Export locale configuration + +**File: `src/i18n/request.ts`** + +- Setup request handler cho next-intl +- Detect locale từ: + + 1. localStorage preferences (nếu có) + 2. Browser language (nếu không có preference) + 3. Default 'en' + +**File: `src/i18n/messages/en.json`** + +- Tất cả English translations được tổ chức theo namespace: + - `common`: buttons, labels, placeholders + - `auth`: login, register, forgot password + - `chat`: chat interface, messages + - `settings`: settings pages + - `errors`: error messages + - `validation`: form validation messages + +**File: `src/i18n/messages/vi.json`** + +- Tất cả Vietnamese translations với cùng structure + +### 3. Tạo I18n Context và Provider + +**File: `src/contexts/i18n-context.tsx`** + +- Context để quản lý locale state +- Functions: `setLocale`, `getLocale` +- Sync với localStorage preferences +- Detect browser language cho user mới + +**File: `src/providers/i18n-provider.tsx`** + +- Provider component wrap app +- Initialize locale từ preferences hoặc browser +- Update HTML lang attribute khi locale thay đổi + +### 4. Tạo custom hook + +**File: `src/hooks/use-translation.ts`** + +- Hook `useTranslation()` để access translations +- Type-safe với TypeScript +- Support namespaces: `t('common.save')`, `t('auth.login')` + +### 5. Update Root Layout + +**File: `src/app/layout.tsx`** + +- Wrap app với `I18nProvider` +- Update `` động +- Remove hardcoded `lang="en"` + +### 6. Migrate Components + +Migrate tất cả hardcoded text trong các files sau: + +**Pages:** + +- `src/app/page.tsx` - Home page +- `src/app/(auth)/login/page.tsx` - Login page +- `src/app/(auth)/register/page.tsx` - Register page +- `src/app/(auth)/forgot-password/page.tsx` - Forgot password +- `src/app/(dashboard)/chat/page.tsx` - Chat page +- `src/app/(dashboard)/settings/preferences/page.tsx` - Preferences +- `src/app/(dashboard)/settings/profile/page.tsx` - Profile +- `src/app/(dashboard)/settings/security/page.tsx` - Security +- `src/app/(dashboard)/settings/api-keys/page.tsx` - API Keys +- `src/app/(dashboard)/settings/layout.tsx` - Settings layout + +**Components:** + +- `src/components/chat/chat-input.tsx` - Chat input +- `src/components/chat/conversation-sidebar.tsx` - Sidebar +- `src/components/chat/message-bubble.tsx` - Message bubble +- `src/components/ui/input.tsx` - Input component (nếu có hardcoded text) +- `src/components/ui/select.tsx` - Select component (nếu có hardcoded text) + +**Stores:** + +- `src/stores/chat-store.ts` - Chat store messages (nếu có) +- `src/stores/auth-store.ts` - Auth store messages (nếu có) + +### 7. Tích hợp với Preferences + +**File: `src/app/(dashboard)/settings/preferences/page.tsx`** + +- Khi user thay đổi language, update i18n context +- Sync với localStorage +- Trigger re-render để update toàn bộ UI + +### 8. Update Validation Schemas + +**Files có Zod schemas:** + +- `src/app/(auth)/login/page.tsx` - Login validation +- `src/app/(auth)/register/page.tsx` - Register validation +- `src/app/(auth)/forgot-password/page.tsx` - Forgot password validation + +- Tạo translation keys cho validation messages +- Update Zod schemas để sử dụng translated messages + +### 9. Format Dates và Numbers + +- Sử dụng next-intl formatters cho dates +- Format relative time trong conversation sidebar +- Format timestamps trong chat messages + +### 10. Testing + +- Test language switching +- Test browser language detection +- Test persistence qua page reloads +- Test tất cả pages với cả 2 ngôn ngữ +- Verify HTML lang attribute updates + +## Translation Structure Example + +```json +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "loading": "Loading...", + "error": "Error" + }, + "auth": { + "login": { + "title": "Sign In", + "email": "Email", + "password": "Password", + "rememberMe": "Remember me", + "forgotPassword": "Forgot password?", + "signUp": "Sign up" + } + }, + "chat": { + "newChat": "New Chat", + "searchPlaceholder": "Search conversations...", + "typeMessage": "Type your message...", + "send": "Send message" + }, + "settings": { + "title": "Settings", + "preferences": { + "title": "Preferences", + "language": "Language", + "theme": "Theme" + } + } +} +``` + +## Lưu ý kỹ thuật + +1. **SSR Compatibility**: next-intl hỗ trợ SSR, nhưng với client-side approach, chúng ta sẽ detect locale ở client-side +2. **Type Safety**: Sử dụng TypeScript để type-check translation keys +3. **Performance**: Lazy load translation files nếu cần +4. **Fallback**: Luôn có fallback về 'en' nếu translation key không tồn tại +5. **Browser Detection**: Sử dụng `navigator.language` hoặc `navigator.languages[0]` để detect + +## Migration Strategy + +1. Setup infrastructure trước (i18n config, provider, hook) +2. Migrate từng page/component một +3. Test sau mỗi migration +4. Update preferences page cuối cùng để hoàn thiện integration + +## Phân chia Tasks cho Multiple Agents + +### Agent Roles + +1. **setup** - Setup infrastructure (phải làm trước, tuần tự) + + - Cài đặt dependencies + - Tạo config, context, provider, hook + - Update root layout + +2. **translations** - Tạo translation files (có thể làm song song) + + - Extract và tạo translation keys cho các namespaces + - Có thể chia theo namespace: common, auth, chat, settings, validation, errors + +3. **migration-auth** - Migrate auth pages (có thể làm song song sau khi có infrastructure) + + - Login, register, forgot-password pages + - Validation schemas cho auth forms + +4. **migration-dashboard** - Migrate dashboard pages (có thể làm song song) + + - Home, chat, settings pages + - Có thể chia theo page: home, chat, preferences, profile, security, api-keys + +5. **migration-components** - Migrate components (có thể làm song song) + + - Chat components + - UI components (nếu có hardcoded text) + +6. **migration-stores** - Migrate stores (có thể làm song song) + + - Chat store, auth store messages + +7. **integration** - Integration tasks (phải làm sau khi có migrations) + + - Preferences language switching + - Date/number formatters + +8. **testing** - Testing tasks (phải làm cuối) + + - Test tất cả functionality + +### Execution Order + +**Phase 1 (Tuần tự):** + +- `setup` agent làm tất cả infrastructure tasks theo thứ tự dependencies + +**Phase 2 (Song song):** + +- `translations` agent có thể làm tất cả translation extraction tasks cùng lúc + +**Phase 3-6 (Song song, sau Phase 1+2):** + +- `migration-auth`, `migration-dashboard`, `migration-components`, `migration-stores` có thể làm song song +- Mỗi agent làm các file độc lập của mình + +**Phase 7 (Sau migrations):** + +- `integration` agent làm integration tasks + +**Phase 8 (Cuối cùng):** + +- `testing` agent test toàn bộ system + +### Dependencies Matrix + +``` +install-deps + └─> create-i18n-config + ├─> create-i18n-request + ├─> create-i18n-context + │ ├─> create-i18n-provider + │ │ └─> update-root-layout + │ └─> create-translation-hook + │ └─> [tất cả migration tasks] + └─> [tất cả translation extraction tasks] + └─> [tất cả migration tasks] +``` + +### Parallelization Opportunities + +- **Translation extraction**: 6 tasks có thể làm song song (common, auth, chat, settings, validation, errors) +- **Auth pages migration**: 3 pages + 3 validation schemas có thể làm song song +- **Dashboard pages migration**: 7 pages có thể làm song song +- **Components migration**: 7+ components có thể làm song song +- **Stores migration**: 2 stores có thể làm song song +- **Testing**: Một số test cases có thể chạy song song (nhưng nên test tuần tự để dễ debug) \ No newline at end of file diff --git a/apps/web-client/package.json b/apps/web-client/package.json index 75704321..412248d4 100644 --- a/apps/web-client/package.json +++ b/apps/web-client/package.json @@ -28,7 +28,8 @@ "zustand": "^4.4.7", "axios": "^1.6.5", "lucide-react": "^0.344.0", - "@tanstack/react-query": "^5.17.0" + "@tanstack/react-query": "^5.17.0", + "next-intl": "^3.15.0" }, "devDependencies": { "@goodgo/eslint-config": "workspace:*", diff --git a/apps/web-client/src/app/(auth)/forgot-password/page.tsx b/apps/web-client/src/app/(auth)/forgot-password/page.tsx index f8fbc1be..31da9c0b 100644 --- a/apps/web-client/src/app/(auth)/forgot-password/page.tsx +++ b/apps/web-client/src/app/(auth)/forgot-password/page.tsx @@ -9,23 +9,21 @@ import { authApi } from '@/services/api/auth.api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'; +import { useTranslation } from '@/hooks/use-translation'; /** - * EN: Forgot password form validation schema using Zod - * VI: Schema validation cho form quên mật khẩu sử dụng Zod + * EN: Create forgot password schema with translated messages + * VI: Tạo forgot password schema với thông báo đã dịch */ -const forgotPasswordSchema = z.object({ - email: z - .string() - .min(1, 'Email is required / Email là bắt buộc') - .email('Invalid email format / Định dạng email không hợp lệ'), -}); +function createForgotPasswordSchema(t: (key: string) => string) { + return z.object({ + email: z + .string() + .min(1, t('validation.emailRequired')) + .email(t('validation.email')), + }); +} -/** - * EN: Type inference from forgot password schema - * VI: Suy luận kiểu từ forgot password schema - */ -type ForgotPasswordFormData = z.infer; /** * EN: Forgot Password page component - allows users to request password reset link @@ -46,6 +44,9 @@ type ForgotPasswordFormData = z.infer; * 4. Success → Redirect to login */ export default function ForgotPasswordPage() { + // EN: Translation hook / VI: Hook translation + const { t } = useTranslation(); + // EN: Success state - shows confirmation after email is sent // VI: Trạng thái thành công - hiển thị xác nhận sau khi email được gửi const [isSuccess, setIsSuccess] = useState(false); @@ -55,6 +56,10 @@ export default function ForgotPasswordPage() { // VI: Trạng thái lỗi chung cho lỗi API const [apiError, setApiError] = useState(''); + // EN: Create schema with translations / VI: Tạo schema với translations + const forgotPasswordSchema = createForgotPasswordSchema(t); + type ForgotPasswordFormData = z.infer; + // EN: React Hook Form setup with Zod resolver // VI: Setup React Hook Form với Zod resolver const { @@ -88,13 +93,13 @@ export default function ForgotPasswordPage() { setSubmittedEmail(data.email); } else { setApiError( - response.error?.message || 'Failed to send reset link / Gửi link đặt lại thất bại' + response.error?.message || t('auth.forgotPassword.failedToSend') ); } } 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 || 'An error occurred / Đã xảy ra lỗi'); + setApiError(err.message || t('errors.generic')); } }; @@ -105,12 +110,12 @@ export default function ForgotPasswordPage() { - Forgot Password / Quên mật khẩu + {t('auth.forgotPassword.title')} {isSuccess - ? 'Check your email for reset instructions / Kiểm tra email để xem hướng dẫn đặt lại' - : 'Enter your email address and we\'ll send you a reset link / Nhập địa chỉ email và chúng tôi sẽ gửi link đặt lại cho bạn'} + ? t('auth.forgotPassword.checkEmail') + : t('auth.forgotPassword.description')} @@ -138,28 +143,18 @@ export default function ForgotPasswordPage() { /> - Reset link sent! / Link đặt lại đã được gửi! + {t('auth.forgotPassword.resetLinkSent')} {/* EN: Detailed success message / VI: Thông báo thành công chi tiết */}

- We've sent a password reset link to{' '} - {submittedEmail} -

-

- Chúng tôi đã gửi link đặt lại mật khẩu đến{' '} - {submittedEmail} + {t('auth.forgotPassword.resetLinkSentDetail', { email: submittedEmail })}

- Please check your inbox and follow the instructions to reset your password. If you - don't see the email, check your spam folder. -

-

- Vui lòng kiểm tra hộp thư và làm theo hướng dẫn để đặt lại mật khẩu. Nếu bạn không - thấy email, hãy kiểm tra thư mục spam. + {t('auth.forgotPassword.checkInbox')}

@@ -175,7 +170,7 @@ export default function ForgotPasswordPage() { setSubmittedEmail(''); }} > - Send to another email / Gửi đến email khác + {t('auth.forgotPassword.sendToAnotherEmail')} @@ -211,7 +206,7 @@ export default function ForgotPasswordPage() { {/* EN: Email input field / VI: Trường nhập email */} {isSubmitting - ? 'Sending... / Đang gửi...' - : 'Send Reset Link / Gửi link đặt lại'} + ? t('auth.forgotPassword.sending') + : t('auth.forgotPassword.sendResetLink')} @@ -246,17 +241,17 @@ export default function ForgotPasswordPage() { href="/login" className="text-sm text-accent-primary hover:brightness-110 transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary rounded" > - ← Back to Login / Quay lại đăng nhập + ← {t('auth.forgotPassword.backToLogin')} {/* EN: Sign up link / VI: Link đăng ký */}

- Don't have an account? / Chưa có tài khoản?{' '} + {t('auth.forgotPassword.noAccount')}{' '} - Sign up / Đăng ký + {t('auth.forgotPassword.signUp')}

diff --git a/apps/web-client/src/app/(auth)/login/page.tsx b/apps/web-client/src/app/(auth)/login/page.tsx index af1cba0e..bc473d9a 100644 --- a/apps/web-client/src/app/(auth)/login/page.tsx +++ b/apps/web-client/src/app/(auth)/login/page.tsx @@ -10,28 +10,30 @@ import { useAuthStore } from '@/stores/auth-store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'; +import { useTranslation } from '@/hooks/use-translation'; /** - * EN: Login form validation schema using Zod - * VI: Schema validation cho form đăng nhập sử dụng Zod + * EN: Create login schema with translated messages + * VI: Tạo login schema với thông báo đã dịch */ -const loginSchema = z.object({ - email: z - .string() - .min(1, 'Email is required / Email là bắt buộc') - .email('Invalid email format / Định dạng email không hợp lệ'), - password: z - .string() - .min(1, 'Password is required / Mật khẩu là bắt buộc') - .min(8, 'Password must be at least 8 characters / Mật khẩu phải có ít nhất 8 ký tự'), - rememberMe: z.boolean().optional(), -}); +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: Type inference from login schema - * VI: Suy luận kiểu từ login schema + * EN: LoginFormData type - will be inferred from schema in component + * VI: Kiểu LoginFormData - sẽ được suy luận từ schema trong component */ -type LoginFormData = z.infer; /** * EN: Login page component for user authentication @@ -46,6 +48,9 @@ type LoginFormData = z.infer; * - 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(); @@ -58,6 +63,10 @@ export default function LoginPage() { // VI: Trạng thái lỗi chung cho lỗi API const [apiError, setApiError] = useState(''); + // EN: Create schema with translations / VI: Tạo schema với translations + const loginSchema = createLoginSchema(t); + type LoginFormData = z.infer; + // EN: React Hook Form setup with Zod resolver // VI: Setup React Hook Form với Zod resolver const { @@ -91,22 +100,22 @@ export default function LoginPage() { } 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 || 'Login failed / Đăng nhập thất bại'); + setApiError(err.message || t('auth.login.loginFailed')); } }; return ( // EN: Centered login form layout with dark mode background // VI: Layout form đăng nhập được căn giữa với nền dark mode -
+
- Sign In / Đăng nhập + {t('auth.login.title')} - Enter your credentials to access your account / Nhập thông tin đăng nhập để truy cập tài khoản + {t('auth.login.description')} @@ -139,7 +148,7 @@ export default function LoginPage() { {/* EN: Email input field / VI: Trường nhập email */} - Remember me / Nhớ đăng nhập + {t('auth.login.rememberMe')} @@ -177,7 +186,7 @@ export default function LoginPage() { href="/forgot-password" className="text-sm text-accent-primary hover:brightness-110 transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary rounded" > - Forgot password? / Quên mật khẩu? + {t('auth.login.forgotPassword')}
@@ -193,18 +202,18 @@ export default function LoginPage() { disabled={isLoading || isSubmitting} > {isLoading || isSubmitting - ? 'Signing in... / Đang đăng nhập...' - : 'Sign In / Đăng nhập'} + ? t('auth.login.signingIn') + : t('auth.login.title')} {/* EN: Sign up link / VI: Link đăng ký */}

- Don't have an account? / Chưa có tài khoản?{' '} + {t('auth.login.noAccount')}{' '} - Sign up / Đăng ký + {t('auth.login.signUp')}

diff --git a/apps/web-client/src/app/(auth)/register/page.tsx b/apps/web-client/src/app/(auth)/register/page.tsx index 01e52f1f..165adc39 100644 --- a/apps/web-client/src/app/(auth)/register/page.tsx +++ b/apps/web-client/src/app/(auth)/register/page.tsx @@ -10,47 +10,45 @@ import { useAuthStore } from '@/stores/auth-store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'; +import { useTranslation } from '@/hooks/use-translation'; /** - * EN: Register form validation schema using Zod - * VI: Schema validation cho form đăng ký sử dụng Zod + * EN: Create register schema with translated messages + * VI: Tạo register schema với thông báo đã dịch */ -const registerSchema = z - .object({ - fullName: z - .string() - .min(1, 'Full name is required / Họ tên là bắt buộc') - .min(2, 'Full name must be at least 2 characters / Họ tên phải có ít nhất 2 ký tự') - .max(100, 'Full name must be less than 100 characters / Họ tên phải ít hơn 100 ký tự'), - email: z - .string() - .min(1, 'Email is required / Email là bắt buộc') - .email('Invalid email format / Định dạng email không hợp lệ'), - password: z - .string() - .min(1, 'Password is required / Mật khẩu là bắt buộc') - .min(8, 'Password must be at least 8 characters / Mật khẩu phải có ít nhất 8 ký tự') - .regex(/[A-Z]/, 'Password must contain at least one uppercase letter / Mật khẩu phải chứa ít nhất một chữ hoa') - .regex(/[a-z]/, 'Password must contain at least one lowercase letter / Mật khẩu phải chứa ít nhất một chữ thường') - .regex(/[0-9]/, 'Password must contain at least one number / Mật khẩu phải chứa ít nhất một số') - .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character / Mật khẩu phải chứa ít nhất một ký tự đặc biệt'), - confirmPassword: z - .string() - .min(1, 'Please confirm your password / Vui lòng xác nhận mật khẩu'), - terms: z.boolean().refine((val) => val === true, { - message: 'You must accept the terms and conditions / Bạn phải chấp nhận điều khoản và điều kiện', - }), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match / Mật khẩu không khớp', - path: ['confirmPassword'], - }); +function createRegisterSchema(t: (key: string) => string) { + return z + .object({ + fullName: z + .string() + .min(1, t('validation.fullNameRequired')) + .min(2, t('validation.fullNameMin')) + .max(100, t('validation.fullNameMax')), + 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')) + .regex(/[A-Z]/, t('validation.passwordUppercase')) + .regex(/[a-z]/, t('validation.passwordLowercase')) + .regex(/[0-9]/, t('validation.passwordNumber')) + .regex(/[^A-Za-z0-9]/, t('validation.passwordSpecial')), + confirmPassword: z + .string() + .min(1, t('validation.passwordConfirmRequired')), + terms: z.boolean().refine((val) => val === true, { + message: t('validation.termsRequired'), + }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: t('validation.passwordConfirm'), + path: ['confirmPassword'], + }); +} -/** - * EN: Type inference from register schema - * VI: Suy luận kiểu từ register schema - */ -type RegisterFormData = z.infer; /** * EN: Password strength levels @@ -100,18 +98,19 @@ function calculatePasswordStrength(password: string): { let strength: PasswordStrength; let feedback: string; + // EN: This will be translated in the component / VI: Sẽ được dịch trong component if (score < 25) { strength = 'weak'; - feedback = 'Weak / Yếu'; + feedback = 'weak'; } else if (score < 50) { strength = 'fair'; - feedback = 'Fair / Trung bình'; + feedback = 'fair'; } else if (score < 75) { strength = 'good'; - feedback = 'Good / Tốt'; + feedback = 'good'; } else { strength = 'strong'; - feedback = 'Strong / Mạnh'; + feedback = 'strong'; } return { strength, percentage: score, feedback }; @@ -129,6 +128,9 @@ function calculatePasswordStrength(password: string): { * - Error handling */ export default function RegisterPage() { + // 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(); @@ -141,6 +143,10 @@ export default function RegisterPage() { // VI: Trạng thái lỗi chung cho lỗi API const [apiError, setApiError] = useState(''); + // EN: Create schema with translations / VI: Tạo schema với translations + const registerSchema = createRegisterSchema(t); + type RegisterFormData = z.infer; + // EN: React Hook Form setup with Zod resolver // VI: Setup React Hook Form với Zod resolver const { @@ -192,7 +198,7 @@ export default function RegisterPage() { } 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 || 'Registration failed / Đăng ký thất bại'); + setApiError(err.message || t('auth.register.registrationFailed')); } }; @@ -220,10 +226,10 @@ export default function RegisterPage() { - Create Account / Tạo tài khoản + {t('auth.register.createAccount')} - Sign up to get started / Đăng ký để bắt đầu + {t('auth.register.signUpToStart')} @@ -256,7 +262,7 @@ export default function RegisterPage() { {/* EN: Full name input field / VI: Trường nhập họ tên */} @@ -320,7 +326,7 @@ export default function RegisterPage() { : 'text-accent-success' }`} > - {passwordStrength.feedback} + {t(`auth.register.${passwordStrength.feedback}`)}

)} @@ -329,8 +335,8 @@ export default function RegisterPage() { {/* EN: Confirm password input field / VI: Trường xác nhận mật khẩu */} - I agree to the{' '} + {t('auth.register.agreeToTerms')}{' '} - Terms and Conditions / Điều khoản và điều kiện + {t('auth.register.termsAndConditions')} @@ -393,18 +399,18 @@ export default function RegisterPage() { disabled={isLoading || isSubmitting} > {isLoading || isSubmitting - ? 'Creating account... / Đang tạo tài khoản...' - : 'Create Account / Tạo tài khoản'} + ? t('auth.register.creatingAccount') + : t('auth.register.createAccount')} {/* EN: Sign in link / VI: Link đăng nhập */}

- Already have an account? / Đã có tài khoản?{' '} + {t('auth.register.alreadyHaveAccount')}{' '} - Sign in / Đăng nhập + {t('auth.register.signIn')}

diff --git a/apps/web-client/src/app/(dashboard)/chat/page.tsx b/apps/web-client/src/app/(dashboard)/chat/page.tsx index 38f9351a..38c34df9 100644 --- a/apps/web-client/src/app/(dashboard)/chat/page.tsx +++ b/apps/web-client/src/app/(dashboard)/chat/page.tsx @@ -10,6 +10,7 @@ const TypingIndicator = React.lazy(() => import('@/components/chat/typing-indica import { LiveRegion } from '@/components/accessibility/live-region'; import { useKeyboardShortcuts, CHAT_SHORTCUTS } from '@/hooks/use-keyboard-shortcuts'; import { useChatStore, MessageSender } from '@/stores/chat-store'; +import { useTranslation } from '@/hooks/use-translation'; /** * EN: Chat page component - Main chat interface @@ -32,6 +33,9 @@ import { useChatStore, MessageSender } from '@/stores/chat-store'; * - Thông báo screen reader */ export default function ChatPage() { + // EN: Translation hook / VI: Hook translation + const { t } = useTranslation(); + const [sidebarVisible, setSidebarVisible] = React.useState(true); const [announcement, setAnnouncement] = React.useState(''); @@ -60,22 +64,22 @@ export default function ChatPage() { } try { await sendMessage(conversationId, content); - setAnnouncement('Message sent / Tin nhắn đã gửi'); + setAnnouncement(t('chat.messageSent')); } catch (error) { - setAnnouncement('Failed to send message / Không thể gửi tin nhắn'); + setAnnouncement(t('chat.messageSendFailed')); } }; // EN: Handle new chat / VI: Xử lý chat mới const handleNewChat = () => { createConversation(); - setAnnouncement('New conversation created / Đã tạo cuộc trò chuyện mới'); + setAnnouncement(t('chat.newConversationCreated')); }; // EN: Handle select conversation / VI: Xử lý chọn conversation const handleSelectConversation = (conversationId: string) => { selectConversation(conversationId); - setAnnouncement(`Switched to conversation / Đã chuyển sang cuộc trò chuyện`); + setAnnouncement(t('chat.switchedToConversation')); }; // EN: Keyboard shortcuts / VI: Phím tắt bàn phím @@ -83,7 +87,7 @@ export default function ChatPage() { { key: CHAT_SHORTCUTS.NEW_CHAT, handler: () => handleNewChat(), - description: 'New chat / Chat mới', + description: t('chat.newChat'), preventDefault: true, }, { @@ -93,7 +97,7 @@ export default function ChatPage() { const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement; searchInput?.focus(); }, - description: 'Open search / Mở tìm kiếm', + description: t('chat.openSearch'), preventDefault: true, }, ]); @@ -120,14 +124,14 @@ export default function ChatPage() { onSidebarToggle={setSidebarVisible} > {/* EN: Messages container / VI: Container tin nhắn */} -
+
{currentMessages.length === 0 ? (

- Start a conversation / Bắt đầu cuộc trò chuyện + {t('chat.startConversation')}

- Type a message below to get started / Nhập tin nhắn bên dưới để bắt đầu + {t('chat.startConversationDesc')}

) : ( @@ -140,7 +144,7 @@ export default function ChatPage() { showActions onCopy={() => { navigator.clipboard.writeText(message.content); - setAnnouncement('Message copied / Đã sao chép tin nhắn'); + setAnnouncement(t('chat.messageCopied')); }} /> )) @@ -149,7 +153,7 @@ export default function ChatPage() { {(() => { const { typingUsers } = useChatStore.getState(); return Object.values(typingUsers).some((typing) => typing) ? ( - }> + }> ) : null; @@ -159,7 +163,7 @@ export default function ChatPage() { {/* EN: Chat input / VI: Chat input */} diff --git a/apps/web-client/src/app/(dashboard)/settings/api-keys/page.tsx b/apps/web-client/src/app/(dashboard)/settings/api-keys/page.tsx index 5a39d906..7fa2a68b 100644 --- a/apps/web-client/src/app/(dashboard)/settings/api-keys/page.tsx +++ b/apps/web-client/src/app/(dashboard)/settings/api-keys/page.tsx @@ -32,6 +32,7 @@ import { AlertCircle, CheckCircle2, } from 'lucide-react'; +import { useTranslation } from '@/hooks/use-translation'; /** * EN: API Key interface @@ -48,32 +49,28 @@ interface ApiKey { } /** - * EN: Create API key form validation schema using Zod - * VI: Schema validation cho form tạo API key sử dụng Zod + * EN: Create API key schema with translated messages + * VI: Tạo API key schema với thông báo đã dịch */ -const createApiKeySchema = 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ự'), - description: z - .string() - .max(500, 'Description must be less than 500 characters / Mô tả phải ít hơn 500 ký tự') - .optional(), -}); - -/** - * EN: Type inference from create API key schema - * VI: Suy luận kiểu từ create API key schema - */ -type CreateApiKeyFormData = z.infer; +function createApiKeySchema(t: (key: string) => string) { + return z.object({ + name: z + .string() + .min(1, t('settings.apiKeys.nameRequired')) + .max(100, t('validation.maxLength', { max: 100 })), + description: z + .string() + .max(500, t('validation.maxLength', { max: 500 })) + .optional(), + }); +} /** * EN: Format timestamp to relative time string * VI: Format timestamp thành chuỗi thời gian tương đối */ -function formatRelativeTime(date: string | null): string { - if (!date) return 'Never / Không bao giờ'; +function formatRelativeTime(date: string | null, t: (key: string, values?: any) => string, locale: string): string { + if (!date) return t('settings.security.never'); const dateObj = new Date(date); const now = new Date(); const diffMs = now.getTime() - dateObj.getTime(); @@ -81,11 +78,11 @@ function formatRelativeTime(date: string | null): string { const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); - if (diffMins < 1) return 'Just now / Vừa xong'; - if (diffMins < 60) return `${diffMins}m ago / ${diffMins} phút trước`; - if (diffHours < 24) return `${diffHours}h ago / ${diffHours} giờ trước`; - if (diffDays < 7) return `${diffDays}d ago / ${diffDays} ngày trước`; - return dateObj.toLocaleDateString('en-US', { + if (diffMins < 1) return t('chat.justNow'); + if (diffMins < 60) return t('chat.minutesAgo', { minutes: diffMins }); + if (diffHours < 24) return t('chat.hoursAgo', { hours: diffHours }); + if (diffDays < 7) return t('chat.daysAgo', { days: diffDays }); + return dateObj.toLocaleDateString(locale === 'vi' ? 'vi-VN' : 'en-US', { month: 'short', day: 'numeric', year: dateObj.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, @@ -96,9 +93,9 @@ function formatRelativeTime(date: string | null): string { * EN: Format date to readable string * VI: Format ngày thành chuỗi dễ đọc */ -function formatDate(date: string | null): string { - if (!date) return 'Never / Không bao giờ'; - return new Date(date).toLocaleDateString('en-US', { +function formatDate(date: string | null, t: (key: string) => string, locale: string): string { + if (!date) return t('settings.security.never'); + return new Date(date).toLocaleDateString(locale === 'vi' ? 'vi-VN' : 'en-US', { year: 'numeric', month: 'short', day: 'numeric', @@ -118,6 +115,13 @@ function formatDate(date: string | null): string { * - Display creation date, last used date, expiration date */ export default function ApiKeysPage() { + // EN: Translation hook / VI: Hook translation + const { t, locale } = useTranslation(); + + // EN: Create schema with translations / VI: Tạo schema với translations + const createApiKeySchema = createApiKeySchema(t); + type CreateApiKeyFormData = z.infer; + // EN: State management / VI: Quản lý state const [apiKeys, setApiKeys] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -171,7 +175,7 @@ export default function ApiKeysPage() { await new Promise((resolve) => setTimeout(resolve, 500)); setApiKeys([]); } catch (err: any) { - setError(err.message || 'Failed to load API keys / Không thể tải API keys'); + setError(err.message || t('settings.apiKeys.failedToLoad')); } finally { setIsLoading(false); } @@ -223,7 +227,7 @@ export default function ApiKeysPage() { }; setApiKeys((prev) => [newKey, ...prev]); } catch (err: any) { - setError(err.message || 'Failed to create API key / Không thể tạo API key'); + setError(err.message || t('settings.apiKeys.failedToCreate')); } }; @@ -232,7 +236,7 @@ export default function ApiKeysPage() { * VI: Xử lý xóa API key */ const handleDeleteKey = async (keyId: string, keyName: string) => { - if (!confirm(`Are you sure you want to delete "${keyName}"? This action cannot be undone. / Bạn có chắc chắn muốn xóa "${keyName}"? Hành động này không thể hoàn tác.`)) { + if (!confirm(t('settings.apiKeys.confirmDelete', { name: keyName }))) { return; } @@ -250,10 +254,10 @@ export default function ApiKeysPage() { // EN: Mock implementation / VI: Implementation mock await new Promise((resolve) => setTimeout(resolve, 500)); setApiKeys((prev) => prev.filter((key) => key.id !== keyId)); - setSuccess('API key deleted successfully / API key đã được xóa thành công'); + setSuccess(t('settings.apiKeys.deletedSuccessfully')); setTimeout(() => setSuccess(''), 3000); } catch (err: any) { - setError(err.message || 'Failed to delete API key / Không thể xóa API key'); + setError(err.message || t('settings.apiKeys.failedToDelete')); } finally { setDeletingKeyId(null); } @@ -285,7 +289,7 @@ export default function ApiKeysPage() { setCopiedKeyId(keyId); setTimeout(() => setCopiedKeyId(null), 2000); } catch (err) { - console.error('Failed to copy to clipboard / Không thể sao chép vào clipboard:', err); + console.error('Failed to copy to clipboard:', err); } }; @@ -310,10 +314,10 @@ export default function ApiKeysPage() { {/* EN: Page header / VI: Header trang */}

- API Keys / Khóa API + {t('settings.apiKeys.title')}

- Manage your API keys for programmatic access / Quản lý khóa API để truy cập theo chương trình + {t('settings.apiKeys.manageKeys')}

@@ -340,10 +344,10 @@ export default function ApiKeysPage() {
- Your API Keys / Khóa API của bạn + {t('settings.apiKeys.yourApiKeys')} - Create and manage API keys for accessing the API / Tạo và quản lý khóa API để truy cập API + {t('settings.apiKeys.createAndManage')}
@@ -360,16 +364,16 @@ export default function ApiKeysPage() { {isLoading ? (
-

Loading API keys... / Đang tải API keys...

+

{t('common.loading')}

) : apiKeys.length === 0 ? (

- No API keys yet / Chưa có API key nào + {t('settings.apiKeys.noApiKeys')}

- Create your first API key to get started / Tạo API key đầu tiên để bắt đầu + {t('settings.apiKeys.createFirstKey')}

) : ( @@ -405,7 +409,7 @@ export default function ApiKeysPage() { size="xs" onClick={() => toggleKeyVisibility(apiKey.id)} className="h-7 w-7 p-0" - aria-label={visibleKeys.has(apiKey.id) ? 'Hide key / Ẩn key' : 'Show key / Hiện key'} + aria-label={visibleKeys.has(apiKey.id) ? t('settings.apiKeys.hide') : t('settings.apiKeys.show')} > {visibleKeys.has(apiKey.id) ? ( @@ -418,7 +422,7 @@ export default function ApiKeysPage() { size="xs" onClick={() => handleCopyKey(apiKey.id)} className="h-7 w-7 p-0" - aria-label="Copy key / Sao chép key" + aria-label={t('settings.apiKeys.copy')} > {copiedKeyId === apiKey.id ? ( @@ -429,16 +433,16 @@ export default function ApiKeysPage() {
- Created: {formatDate(apiKey.createdAt)} / Tạo: {formatDate(apiKey.createdAt)} + {t('settings.apiKeys.created')}: {formatDate(apiKey.createdAt, t, locale)} {apiKey.lastUsedAt && ( - Last used: {formatRelativeTime(apiKey.lastUsedAt)} / Lần cuối dùng: {formatRelativeTime(apiKey.lastUsedAt)} + {t('settings.apiKeys.lastUsed')}: {formatRelativeTime(apiKey.lastUsedAt, t, locale)} )} {apiKey.expiresAt && ( - Expires: {formatDate(apiKey.expiresAt)} / Hết hạn: {formatDate(apiKey.expiresAt)} + {t('settings.apiKeys.expires')}: {formatDate(apiKey.expiresAt, t, locale)} )}
@@ -449,7 +453,7 @@ export default function ApiKeysPage() { onClick={() => handleDeleteKey(apiKey.id, apiKey.name)} loading={deletingKeyId === apiKey.id} className="text-accent-error hover:brightness-110 hover:bg-accent-error/10 ml-4" - aria-label={`Delete ${apiKey.name} / Xóa ${apiKey.name}`} + aria-label={`${t('settings.apiKeys.delete')} ${apiKey.name}`} > @@ -465,26 +469,16 @@ export default function ApiKeysPage() { - Security Best Practices / Thực hành bảo mật tốt nhất + {t('settings.apiKeys.securityBestPractices', { defaultValue: 'Security Best Practices' })}
    -
  • - Keep your API keys secure and never share them publicly / Giữ khóa API của bạn an toàn và không bao giờ chia sẻ công khai -
  • -
  • - Use environment variables or secure secret management tools / Sử dụng biến môi trường hoặc công cụ quản lý bí mật an toàn -
  • -
  • - Rotate your API keys regularly / Xoay khóa API thường xuyên -
  • -
  • - Delete unused API keys immediately / Xóa khóa API không sử dụng ngay lập tức -
  • -
  • - If a key is compromised, revoke it immediately and create a new one / Nếu khóa bị xâm phạm, hãy thu hồi ngay lập tức và tạo khóa mới -
  • +
  • {t('settings.apiKeys.practice1', { defaultValue: 'Keep your API keys secure and never share them publicly' })}
  • +
  • {t('settings.apiKeys.practice2', { defaultValue: 'Use environment variables or secure secret management tools' })}
  • +
  • {t('settings.apiKeys.practice3', { defaultValue: 'Rotate your API keys regularly' })}
  • +
  • {t('settings.apiKeys.practice4', { defaultValue: 'Delete unused API keys immediately' })}
  • +
  • {t('settings.apiKeys.practice5', { defaultValue: 'If a key is compromised, revoke it immediately and create a new one' })}
@@ -493,9 +487,9 @@ export default function ApiKeysPage() { - Create API Key / Tạo khóa API + {t('settings.apiKeys.createApiKey')} - Create a new API key for programmatic access / Tạo khóa API mới để truy cập theo chương trình + {t('settings.apiKeys.createForAccess', { defaultValue: 'Create a new API key for programmatic access' })} @@ -509,12 +503,12 @@ export default function ApiKeysPage() { )} @@ -523,12 +517,12 @@ export default function ApiKeysPage() { htmlFor="description" className="block text-sm font-medium text-text-secondary mb-2" > - Description / Mô tả (Optional / Tùy chọn) + {t('settings.apiKeys.description')} ({t('common.optional', { defaultValue: 'Optional' })})