Implement internationalization support and enhance user experience in web-client
- Added `next-intl` dependency for improved internationalization capabilities. - Integrated translation hooks across various components, including authentication, chat, and settings, to support dynamic language switching. - Updated UI elements to utilize translated strings for better accessibility and user experience. - Refactored forms and validation schemas to include localized messages for error handling and user prompts. - Enhanced chat functionality with localized messages for actions and notifications. These changes aim to provide a more inclusive experience for users by supporting multiple languages and improving overall usability.
This commit is contained in:
@@ -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:*",
|
||||
|
||||
@@ -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<typeof forgotPasswordSchema>;
|
||||
|
||||
/**
|
||||
* EN: Forgot Password page component - allows users to request password reset link
|
||||
@@ -46,6 +44,9 @@ type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
||||
* 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<string>('');
|
||||
|
||||
// EN: Create schema with translations / VI: Tạo schema với translations
|
||||
const forgotPasswordSchema = createForgotPasswordSchema(t);
|
||||
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
||||
|
||||
// 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() {
|
||||
<Card className="w-full max-w-md" hover={false} bordered>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl font-semibold">
|
||||
Forgot Password / Quên mật khẩu
|
||||
{t('auth.forgotPassword.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
{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')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -138,28 +143,18 @@ export default function ForgotPasswordPage() {
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium">
|
||||
Reset link sent! / Link đặt lại đã được gửi!
|
||||
{t('auth.forgotPassword.resetLinkSent')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* EN: Detailed success message / VI: Thông báo thành công chi tiết */}
|
||||
<div className="space-y-3 text-sm text-text-secondary">
|
||||
<p>
|
||||
We've sent a password reset link to{' '}
|
||||
<strong className="text-text-primary">{submittedEmail}</strong>
|
||||
</p>
|
||||
<p className="text-text-tertiary">
|
||||
Chúng tôi đã gửi link đặt lại mật khẩu đến{' '}
|
||||
<strong className="text-text-secondary">{submittedEmail}</strong>
|
||||
{t('auth.forgotPassword.resetLinkSentDetail', { email: submittedEmail })}
|
||||
</p>
|
||||
<div className="pt-2 border-t border-border-primary">
|
||||
<p className="text-text-tertiary">
|
||||
Please check your inbox and follow the instructions to reset your password. If you
|
||||
don't see the email, check your spam folder.
|
||||
</p>
|
||||
<p className="text-text-tertiary mt-2">
|
||||
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')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,7 +170,7 @@ export default function ForgotPasswordPage() {
|
||||
setSubmittedEmail('');
|
||||
}}
|
||||
>
|
||||
Send to another email / Gửi đến email khác
|
||||
{t('auth.forgotPassword.sendToAnotherEmail')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -211,7 +206,7 @@ export default function ForgotPasswordPage() {
|
||||
{/* EN: Email input field / VI: Trường nhập email */}
|
||||
<Input
|
||||
type="email"
|
||||
label="Email / Email"
|
||||
label={t('auth.forgotPassword.email')}
|
||||
placeholder="you@example.com"
|
||||
validationState={errors.email ? 'error' : 'default'}
|
||||
errorMessage={errors.email?.message}
|
||||
@@ -233,8 +228,8 @@ export default function ForgotPasswordPage() {
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Sending... / Đang gửi...'
|
||||
: 'Send Reset Link / Gửi link đặt lại'}
|
||||
? t('auth.forgotPassword.sending')
|
||||
: t('auth.forgotPassword.sendResetLink')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
@@ -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')}
|
||||
</Link>
|
||||
|
||||
{/* EN: Sign up link / VI: Link đăng ký */}
|
||||
<p className="text-sm text-center text-text-tertiary">
|
||||
Don't have an account? / Chưa có tài khoản?{' '}
|
||||
{t('auth.forgotPassword.noAccount')}{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-accent-primary hover:brightness-110 font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary rounded"
|
||||
>
|
||||
Sign up / Đăng ký
|
||||
{t('auth.forgotPassword.signUp')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
|
||||
@@ -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<typeof loginSchema>;
|
||||
|
||||
/**
|
||||
* EN: Login page component for user authentication
|
||||
@@ -46,6 +48,9 @@ type LoginFormData = z.infer<typeof loginSchema>;
|
||||
* - 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<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 {
|
||||
@@ -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
|
||||
<main role="main" aria-label="Login page / Trang đăng nhập">
|
||||
<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">
|
||||
Sign In / Đăng nhập
|
||||
{t('auth.login.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
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')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -139,7 +148,7 @@ export default function LoginPage() {
|
||||
{/* EN: Email input field / VI: Trường nhập email */}
|
||||
<Input
|
||||
type="email"
|
||||
label="Email / Email"
|
||||
label={t('auth.login.email')}
|
||||
placeholder="you@example.com"
|
||||
validationState={errors.email ? 'error' : 'default'}
|
||||
errorMessage={errors.email?.message}
|
||||
@@ -151,8 +160,8 @@ export default function LoginPage() {
|
||||
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
|
||||
<Input
|
||||
type="password"
|
||||
label="Password / Mật khẩu"
|
||||
placeholder="Enter your password / Nhập mật khẩu"
|
||||
label={t('auth.login.password')}
|
||||
placeholder={t('auth.login.password')}
|
||||
validationState={errors.password ? 'error' : 'default'}
|
||||
errorMessage={errors.password?.message}
|
||||
{...register('password')}
|
||||
@@ -169,7 +178,7 @@ export default function LoginPage() {
|
||||
className="w-4 h-4 rounded border-border-primary bg-bg-secondary text-accent-primary focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
|
||||
Remember me / Nhớ đăng nhập
|
||||
{t('auth.login.rememberMe')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -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')}
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -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')}
|
||||
</Button>
|
||||
|
||||
{/* EN: Sign up link / VI: Link đăng ký */}
|
||||
<p className="text-sm text-center text-text-tertiary">
|
||||
Don't have an account? / Chưa có tài khoản?{' '}
|
||||
{t('auth.login.noAccount')}{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-accent-primary hover:brightness-110 font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary rounded"
|
||||
>
|
||||
Sign up / Đăng ký
|
||||
{t('auth.login.signUp')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
|
||||
@@ -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<typeof registerSchema>;
|
||||
|
||||
/**
|
||||
* 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<string>('');
|
||||
|
||||
// EN: Create schema with translations / VI: Tạo schema với translations
|
||||
const registerSchema = createRegisterSchema(t);
|
||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
|
||||
// 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() {
|
||||
<Card className="w-full max-w-md" hover={false} bordered>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl font-semibold">
|
||||
Create Account / Tạo tài khoản
|
||||
{t('auth.register.createAccount')}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Sign up to get started / Đăng ký để bắt đầu
|
||||
{t('auth.register.signUpToStart')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -256,7 +262,7 @@ export default function RegisterPage() {
|
||||
{/* EN: Full name input field / VI: Trường nhập họ tên */}
|
||||
<Input
|
||||
type="text"
|
||||
label="Full Name / Họ tên"
|
||||
label={t('auth.register.fullName')}
|
||||
placeholder="John Doe"
|
||||
validationState={errors.fullName ? 'error' : 'default'}
|
||||
errorMessage={errors.fullName?.message}
|
||||
@@ -268,7 +274,7 @@ export default function RegisterPage() {
|
||||
{/* EN: Email input field / VI: Trường nhập email */}
|
||||
<Input
|
||||
type="email"
|
||||
label="Email / Email"
|
||||
label={t('auth.register.email')}
|
||||
placeholder="you@example.com"
|
||||
validationState={errors.email ? 'error' : 'default'}
|
||||
errorMessage={errors.email?.message}
|
||||
@@ -281,8 +287,8 @@ export default function RegisterPage() {
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
label="Password / Mật khẩu"
|
||||
placeholder="Create a strong password / Tạo mật khẩu mạnh"
|
||||
label={t('auth.register.password')}
|
||||
placeholder={t('auth.register.createStrongPassword')}
|
||||
validationState={errors.password ? 'error' : 'default'}
|
||||
errorMessage={errors.password?.message}
|
||||
{...register('password')}
|
||||
@@ -304,7 +310,7 @@ export default function RegisterPage() {
|
||||
aria-valuenow={passwordStrength.percentage}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`Password strength: ${passwordStrength.feedback}`}
|
||||
aria-label={t('auth.register.passwordStrength', { strength: t(`auth.register.${passwordStrength.feedback}`) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -320,7 +326,7 @@ export default function RegisterPage() {
|
||||
: 'text-accent-success'
|
||||
}`}
|
||||
>
|
||||
{passwordStrength.feedback}
|
||||
{t(`auth.register.${passwordStrength.feedback}`)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -329,8 +335,8 @@ export default function RegisterPage() {
|
||||
{/* EN: Confirm password input field / VI: Trường xác nhận mật khẩu */}
|
||||
<Input
|
||||
type="password"
|
||||
label="Confirm Password / Xác nhận mật khẩu"
|
||||
placeholder="Re-enter your password / Nhập lại mật khẩu"
|
||||
label={t('auth.register.confirmPassword')}
|
||||
placeholder={t('auth.register.reEnterPassword')}
|
||||
validationState={errors.confirmPassword ? 'error' : 'default'}
|
||||
errorMessage={errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
@@ -349,14 +355,14 @@ export default function RegisterPage() {
|
||||
aria-invalid={errors.terms ? 'true' : 'false'}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
|
||||
I agree to the{' '}
|
||||
{t('auth.register.agreeToTerms')}{' '}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-accent-primary hover:brightness-110 underline focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary rounded"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Terms and Conditions / Điều khoản và điều kiện
|
||||
{t('auth.register.termsAndConditions')}
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
@@ -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')}
|
||||
</Button>
|
||||
|
||||
{/* EN: Sign in link / VI: Link đăng nhập */}
|
||||
<p className="text-sm text-center text-text-tertiary">
|
||||
Already have an account? / Đã có tài khoản?{' '}
|
||||
{t('auth.register.alreadyHaveAccount')}{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-accent-primary hover:brightness-110 font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary rounded"
|
||||
>
|
||||
Sign in / Đăng nhập
|
||||
{t('auth.register.signIn')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
|
||||
@@ -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<string>('');
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4" role="log" aria-label="Chat messages / Tin nhắn chat">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4" role="log" aria-label={t('chat.messages')}>
|
||||
{currentMessages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<p className="text-text-tertiary text-lg mb-2">
|
||||
Start a conversation / Bắt đầu cuộc trò chuyện
|
||||
{t('chat.startConversation')}
|
||||
</p>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
Type a message below to get started / Nhập tin nhắn bên dưới để bắt đầu
|
||||
{t('chat.startConversationDesc')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -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) ? (
|
||||
<React.Suspense fallback={<div className="px-4 py-3" aria-label="Loading typing indicator / Đang tải chỉ báo đang gõ" />}>
|
||||
<React.Suspense fallback={<div className="px-4 py-3" aria-label={t('chat.loadingTypingIndicator')} />}>
|
||||
<TypingIndicator />
|
||||
</React.Suspense>
|
||||
) : null;
|
||||
@@ -159,7 +163,7 @@ export default function ChatPage() {
|
||||
{/* EN: Chat input / VI: Chat input */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
placeholder="Type your message... / Nhập tin nhắn..."
|
||||
placeholder={t('chat.typeMessage')}
|
||||
/>
|
||||
</ChatLayout>
|
||||
</>
|
||||
|
||||
@@ -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<typeof createApiKeySchema>;
|
||||
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<typeof createApiKeySchema>;
|
||||
|
||||
// EN: State management / VI: Quản lý state
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
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 */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-text-primary">
|
||||
API Keys / Khóa API
|
||||
{t('settings.apiKeys.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
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')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -340,10 +344,10 @@ export default function ApiKeysPage() {
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-accent-primary" />
|
||||
Your API Keys / Khóa API của bạn
|
||||
{t('settings.apiKeys.yourApiKeys')}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
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')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -352,7 +356,7 @@ export default function ApiKeysPage() {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create API Key / Tạo khóa API
|
||||
{t('settings.apiKeys.createApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -360,16 +364,16 @@ export default function ApiKeysPage() {
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-text-tertiary">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent mb-4"></div>
|
||||
<p>Loading API keys... / Đang tải API keys...</p>
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Key className="h-12 w-12 text-text-tertiary mx-auto mb-4" />
|
||||
<p className="text-text-secondary font-medium mb-2">
|
||||
No API keys yet / Chưa có API key nào
|
||||
{t('settings.apiKeys.noApiKeys')}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mb-6">
|
||||
Create your first API key to get started / Tạo API key đầu tiên để bắt đầu
|
||||
{t('settings.apiKeys.createFirstKey')}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -377,7 +381,7 @@ export default function ApiKeysPage() {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create API Key / Tạo khóa API
|
||||
{t('settings.apiKeys.createApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -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) ? (
|
||||
<EyeOff className="h-4 w-4 text-text-tertiary" />
|
||||
@@ -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 ? (
|
||||
<Check className="h-4 w-4 text-accent-success" />
|
||||
@@ -429,16 +433,16 @@ export default function ApiKeysPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-text-tertiary">
|
||||
<span>
|
||||
Created: {formatDate(apiKey.createdAt)} / Tạo: {formatDate(apiKey.createdAt)}
|
||||
{t('settings.apiKeys.created')}: {formatDate(apiKey.createdAt, t, locale)}
|
||||
</span>
|
||||
{apiKey.lastUsedAt && (
|
||||
<span>
|
||||
Last used: {formatRelativeTime(apiKey.lastUsedAt)} / Lần cuối dùng: {formatRelativeTime(apiKey.lastUsedAt)}
|
||||
{t('settings.apiKeys.lastUsed')}: {formatRelativeTime(apiKey.lastUsedAt, t, locale)}
|
||||
</span>
|
||||
)}
|
||||
{apiKey.expiresAt && (
|
||||
<span className={new Date(apiKey.expiresAt) < new Date() ? 'text-accent-error' : ''}>
|
||||
Expires: {formatDate(apiKey.expiresAt)} / Hết hạn: {formatDate(apiKey.expiresAt)}
|
||||
{t('settings.apiKeys.expires')}: {formatDate(apiKey.expiresAt, t, locale)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -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}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -465,26 +469,16 @@ export default function ApiKeysPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertCircle className="h-5 w-5 text-accent-warning" />
|
||||
Security Best Practices / Thực hành bảo mật tốt nhất
|
||||
{t('settings.apiKeys.securityBestPractices', { defaultValue: 'Security Best Practices' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-text-secondary">
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>
|
||||
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
|
||||
</li>
|
||||
<li>
|
||||
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
|
||||
</li>
|
||||
<li>
|
||||
Rotate your API keys regularly / Xoay khóa API thường xuyên
|
||||
</li>
|
||||
<li>
|
||||
Delete unused API keys immediately / Xóa khóa API không sử dụng ngay lập tức
|
||||
</li>
|
||||
<li>
|
||||
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
|
||||
</li>
|
||||
<li>{t('settings.apiKeys.practice1', { defaultValue: 'Keep your API keys secure and never share them publicly' })}</li>
|
||||
<li>{t('settings.apiKeys.practice2', { defaultValue: 'Use environment variables or secure secret management tools' })}</li>
|
||||
<li>{t('settings.apiKeys.practice3', { defaultValue: 'Rotate your API keys regularly' })}</li>
|
||||
<li>{t('settings.apiKeys.practice4', { defaultValue: 'Delete unused API keys immediately' })}</li>
|
||||
<li>{t('settings.apiKeys.practice5', { defaultValue: 'If a key is compromised, revoke it immediately and create a new one' })}</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -493,9 +487,9 @@ export default function ApiKeysPage() {
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key / Tạo khóa API</DialogTitle>
|
||||
<DialogTitle>{t('settings.apiKeys.createApiKey')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
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' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -509,12 +503,12 @@ export default function ApiKeysPage() {
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Name / Tên"
|
||||
placeholder="e.g., Production Key, Development Key / VD: Production Key, Development Key"
|
||||
label={t('settings.apiKeys.name')}
|
||||
placeholder={t('settings.apiKeys.namePlaceholder', { defaultValue: 'e.g., Production Key, Development Key' })}
|
||||
{...register('name')}
|
||||
errorMessage={errors.name?.message}
|
||||
validationState={errors.name ? 'error' : 'default'}
|
||||
helperText="A descriptive name for this API key / Tên mô tả cho khóa API này"
|
||||
helperText={t('settings.apiKeys.nameHelper', { defaultValue: 'A descriptive name for this API key' })}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -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' })})
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={3}
|
||||
placeholder="Optional description for this API key / Mô tả tùy chọn cho khóa API này"
|
||||
placeholder={t('settings.apiKeys.descriptionPlaceholder', { defaultValue: 'Optional description for this API key' })}
|
||||
{...register('description')}
|
||||
className="flex w-full rounded-md border border-border-primary bg-bg-secondary px-3 py-2 text-base text-text-primary placeholder:text-text-tertiary transition-all duration-[150ms] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:border-accent-primary focus-visible:ring-accent-primary focus-visible:shadow-[0_0_20px_rgba(59,130,246,0.3)] disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-bg-tertiary"
|
||||
/>
|
||||
@@ -550,10 +544,10 @@ export default function ApiKeysPage() {
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
Cancel / Hủy
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Create API Key / Tạo khóa API
|
||||
{t('settings.apiKeys.createApiKey')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -566,10 +560,10 @@ export default function ApiKeysPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-accent-success" />
|
||||
API Key Created / Khóa API đã được tạo
|
||||
{t('settings.apiKeys.newApiKeyCreated')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your new API key for "{newApiKeyName}" / Khóa API mới cho "{newApiKeyName}"
|
||||
{t('settings.apiKeys.newKeyFor', { name: newApiKeyName, defaultValue: `Your new API key for "${newApiKeyName}"` })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -577,10 +571,10 @@ export default function ApiKeysPage() {
|
||||
{/* EN: Warning message / VI: Thông báo cảnh báo */}
|
||||
<div className="p-3 rounded-md bg-accent-warning/10 border border-accent-warning text-accent-warning text-sm">
|
||||
<p className="font-medium mb-1">
|
||||
Important / Quan trọng
|
||||
{t('settings.apiKeys.important', { defaultValue: 'Important' })}
|
||||
</p>
|
||||
<p>
|
||||
Copy this API key now. You won't be able to see it again! / Sao chép khóa API này ngay bây giờ. Bạn sẽ không thể xem lại!
|
||||
{t('settings.apiKeys.saveKeySecurely')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -605,7 +599,7 @@ export default function ApiKeysPage() {
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy API Key / Sao chép khóa API
|
||||
{t('settings.apiKeys.copyApiKey', { defaultValue: 'Copy API Key' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -621,7 +615,7 @@ export default function ApiKeysPage() {
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
I've copied the key / Tôi đã sao chép khóa
|
||||
{t('settings.apiKeys.iveCopied', { defaultValue: 'I\'ve copied the key' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -11,49 +11,52 @@ import {
|
||||
CreditCard,
|
||||
Key,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: Settings navigation tabs configuration
|
||||
* VI: Cấu hình các tab điều hướng Settings
|
||||
*/
|
||||
const settingsTabs = [
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile / Hồ sơ',
|
||||
href: '/settings/profile',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
id: 'preferences',
|
||||
label: 'Preferences / Tùy chọn',
|
||||
href: '/settings/preferences',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
label: 'Security / Bảo mật',
|
||||
href: '/settings/security',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Notifications / Thông báo',
|
||||
href: '/settings/notifications',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
label: 'Billing / Thanh toán',
|
||||
href: '/settings/billing',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
id: 'api-keys',
|
||||
label: 'API Keys / Khóa API',
|
||||
href: '/settings/api-keys',
|
||||
icon: Key,
|
||||
},
|
||||
];
|
||||
function getSettingsTabs(t: (key: string) => string) {
|
||||
return [
|
||||
{
|
||||
id: 'profile',
|
||||
label: t('settings.profile.label'),
|
||||
href: '/settings/profile',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
id: 'preferences',
|
||||
label: t('settings.preferences.label'),
|
||||
href: '/settings/preferences',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
label: t('settings.security.label'),
|
||||
href: '/settings/security',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: t('settings.notifications.label'),
|
||||
href: '/settings/notifications',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
label: t('settings.billing.label'),
|
||||
href: '/settings/billing',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
id: 'api-keys',
|
||||
label: t('settings.apiKeys.label'),
|
||||
href: '/settings/api-keys',
|
||||
icon: Key,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Settings layout component with tab navigation
|
||||
@@ -70,7 +73,10 @@ export default function SettingsLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const settingsTabs = getSettingsTabs(t);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
@@ -79,11 +85,10 @@ export default function SettingsLayout({
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-6">
|
||||
<h1 className="text-3xl font-bold text-text-primary">
|
||||
Settings / Cài đặt
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
Manage your account settings and preferences / Quản lý cài đặt và
|
||||
tùy chọn tài khoản
|
||||
{t('settings.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +102,7 @@ export default function SettingsLayout({
|
||||
<aside className="lg:col-span-3">
|
||||
<nav
|
||||
className="space-y-1"
|
||||
aria-label="Settings navigation / Điều hướng Settings"
|
||||
aria-label={t('settings.navigation')}
|
||||
>
|
||||
{settingsTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { useI18n } from '@/contexts/i18n-context';
|
||||
import { type Locale } from '@/i18n/config';
|
||||
|
||||
/**
|
||||
* EN: Language options for preferences
|
||||
@@ -66,6 +69,9 @@ const defaultPreferences: Preferences = {
|
||||
* - Accessibility options (High contrast mode, Screen reader optimizations)
|
||||
*/
|
||||
export default function PreferencesPage() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
const { locale, setLocale } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [preferences, setPreferences] = React.useState<Preferences>(defaultPreferences);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
@@ -79,18 +85,25 @@ export default function PreferencesPage() {
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as Partial<Preferences>;
|
||||
const loaded = { ...defaultPreferences, ...parsed, theme };
|
||||
const loaded = { ...defaultPreferences, ...parsed, theme, language: locale as Language };
|
||||
setPreferences(loaded);
|
||||
} catch {
|
||||
// EN: Invalid stored data, use defaults / VI: Dữ liệu lưu không hợp lệ, dùng mặc định
|
||||
setPreferences({ ...defaultPreferences, theme });
|
||||
setPreferences({ ...defaultPreferences, theme, language: locale as Language });
|
||||
}
|
||||
} else {
|
||||
setPreferences({ ...defaultPreferences, theme });
|
||||
setPreferences({ ...defaultPreferences, theme, language: locale as Language });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // EN: Only run on mount / VI: Chỉ chạy khi mount
|
||||
|
||||
// EN: Sync locale with preferences / VI: Đồng bộ locale với preferences
|
||||
React.useEffect(() => {
|
||||
if (preferences.language !== locale) {
|
||||
setPreferences((prev) => ({ ...prev, language: locale as Language }));
|
||||
}
|
||||
}, [locale, preferences.language]);
|
||||
|
||||
// EN: Update preferences state / VI: Cập nhật state preferences
|
||||
const updatePreference = <K extends keyof Preferences>(
|
||||
key: K,
|
||||
@@ -112,7 +125,7 @@ export default function PreferencesPage() {
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to save preferences / Không thể lưu preferences:', error);
|
||||
console.error('Failed to save preferences:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -124,34 +137,43 @@ export default function PreferencesPage() {
|
||||
updatePreference('theme', newTheme);
|
||||
};
|
||||
|
||||
// EN: Handle language change / VI: Xử lý thay đổi ngôn ngữ
|
||||
const handleLanguageChange = (newLanguage: string) => {
|
||||
const lang = newLanguage as Locale;
|
||||
setLocale(lang);
|
||||
updatePreference('language', lang as Language);
|
||||
// EN: Trigger a re-render by updating preferences / VI: Kích hoạt re-render bằng cách cập nhật preferences
|
||||
setPreferences((prev) => ({ ...prev, language: lang as Language }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* EN: Page header / VI: Header trang */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-text-primary">
|
||||
Preferences / Tùy chọn
|
||||
{t('settings.preferences.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
Customize your experience and application settings / Tùy chỉnh trải nghiệm và cài đặt ứng dụng
|
||||
{t('settings.preferences.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Language & Theme Section / VI: Phần Ngôn ngữ & Theme */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Language & Theme / Ngôn ngữ & Giao diện</CardTitle>
|
||||
<CardTitle>{t('settings.preferences.languageAndTheme')}</CardTitle>
|
||||
<CardDescription>
|
||||
Choose your preferred language and appearance / Chọn ngôn ngữ và giao diện ưa thích
|
||||
{t('settings.preferences.languageAndThemeDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* EN: Language selection / VI: Chọn ngôn ngữ */}
|
||||
<div>
|
||||
<Select
|
||||
label="Language / Ngôn ngữ"
|
||||
label={t('settings.preferences.language')}
|
||||
value={preferences.language}
|
||||
onChange={(e) => updatePreference('language', e.target.value as Language)}
|
||||
helperText="Select your preferred language / Chọn ngôn ngữ ưa thích"
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
helperText={t('settings.preferences.languageHelper')}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="vi">Tiếng Việt</option>
|
||||
@@ -161,14 +183,14 @@ export default function PreferencesPage() {
|
||||
{/* EN: Theme selection / VI: Chọn theme */}
|
||||
<div>
|
||||
<Select
|
||||
label="Theme / Giao diện"
|
||||
label={t('settings.preferences.theme')}
|
||||
value={preferences.theme}
|
||||
onChange={(e) => handleThemeChange(e.target.value as ThemeMode)}
|
||||
helperText="Choose your preferred theme / Chọn giao diện ưa thích"
|
||||
helperText={t('settings.preferences.themeHelper')}
|
||||
>
|
||||
<option value="light">Light / Sáng</option>
|
||||
<option value="dark">Dark / Tối</option>
|
||||
<option value="system">System / Hệ thống</option>
|
||||
<option value="light">{t('settings.preferences.light')}</option>
|
||||
<option value="dark">{t('settings.preferences.dark')}</option>
|
||||
<option value="system">{t('settings.preferences.system')}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -177,9 +199,9 @@ export default function PreferencesPage() {
|
||||
{/* EN: Chat Settings Section / VI: Phần Cài đặt Chat */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Chat Settings / Cài đặt Chat</CardTitle>
|
||||
<CardTitle>{t('settings.preferences.chatSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
Customize your chat experience / Tùy chỉnh trải nghiệm chat
|
||||
{t('settings.preferences.chatSettingsDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
@@ -190,10 +212,10 @@ export default function PreferencesPage() {
|
||||
htmlFor="chat-auto-scroll"
|
||||
className="text-sm font-medium text-text-primary cursor-pointer"
|
||||
>
|
||||
Auto-scroll / Tự động cuộn
|
||||
{t('settings.preferences.autoScroll')}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
Automatically scroll to the latest message / Tự động cuộn đến tin nhắn mới nhất
|
||||
{t('settings.preferences.autoScrollDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -210,10 +232,10 @@ export default function PreferencesPage() {
|
||||
htmlFor="chat-show-timestamps"
|
||||
className="text-sm font-medium text-text-primary cursor-pointer"
|
||||
>
|
||||
Show timestamps / Hiển thị thời gian
|
||||
{t('settings.preferences.showTimestamps')}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
Display message timestamps / Hiển thị thời gian của tin nhắn
|
||||
{t('settings.preferences.showTimestampsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -226,29 +248,29 @@ export default function PreferencesPage() {
|
||||
{/* EN: Message grouping / VI: Nhóm tin nhắn */}
|
||||
<div>
|
||||
<Select
|
||||
label="Message grouping / Nhóm tin nhắn"
|
||||
label={t('settings.preferences.messageGrouping')}
|
||||
value={preferences.chatMessageGrouping}
|
||||
onChange={(e) => updatePreference('chatMessageGrouping', e.target.value as MessageGrouping)}
|
||||
helperText="Choose how messages are grouped / Chọn cách nhóm tin nhắn"
|
||||
helperText={t('settings.preferences.messageGroupingHelper')}
|
||||
>
|
||||
<option value="none">None / Không</option>
|
||||
<option value="by-author">By author / Theo tác giả</option>
|
||||
<option value="by-time">By time / Theo thời gian</option>
|
||||
<option value="none">{t('settings.preferences.none')}</option>
|
||||
<option value="by-author">{t('settings.preferences.byAuthor')}</option>
|
||||
<option value="by-time">{t('settings.preferences.byTime')}</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* EN: Font size / VI: Kích thước font */}
|
||||
<div>
|
||||
<Select
|
||||
label="Font size / Kích thước chữ"
|
||||
label={t('settings.preferences.fontSize')}
|
||||
value={preferences.chatFontSize}
|
||||
onChange={(e) => updatePreference('chatFontSize', e.target.value as FontSize)}
|
||||
helperText="Choose your preferred font size / Chọn kích thước chữ ưa thích"
|
||||
helperText={t('settings.preferences.fontSizeHelper')}
|
||||
>
|
||||
<option value="small">Small / Nhỏ</option>
|
||||
<option value="medium">Medium / Trung bình</option>
|
||||
<option value="large">Large / Lớn</option>
|
||||
<option value="xlarge">Extra Large / Rất lớn</option>
|
||||
<option value="small">{t('settings.preferences.small')}</option>
|
||||
<option value="medium">{t('settings.preferences.medium')}</option>
|
||||
<option value="large">{t('settings.preferences.large')}</option>
|
||||
<option value="xlarge">{t('settings.preferences.extraLarge')}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -257,9 +279,9 @@ export default function PreferencesPage() {
|
||||
{/* EN: Accessibility Section / VI: Phần Khả năng truy cập */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Accessibility / Khả năng truy cập</CardTitle>
|
||||
<CardTitle>{t('settings.preferences.accessibility')}</CardTitle>
|
||||
<CardDescription>
|
||||
Improve accessibility and usability / Cải thiện khả năng truy cập và sử dụng
|
||||
{t('settings.preferences.accessibilityDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
@@ -270,10 +292,10 @@ export default function PreferencesPage() {
|
||||
htmlFor="accessibility-high-contrast"
|
||||
className="text-sm font-medium text-text-primary cursor-pointer"
|
||||
>
|
||||
High contrast mode / Chế độ tương phản cao
|
||||
{t('settings.preferences.highContrast')}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
Increase color contrast for better visibility / Tăng độ tương phản màu sắc để dễ nhìn hơn
|
||||
{t('settings.preferences.highContrastDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -290,10 +312,10 @@ export default function PreferencesPage() {
|
||||
htmlFor="accessibility-screen-reader"
|
||||
className="text-sm font-medium text-[#FAFAFA] cursor-pointer"
|
||||
>
|
||||
Screen reader optimizations / Tối ưu cho screen reader
|
||||
{t('settings.preferences.screenReader')}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-[#A0A0A0]">
|
||||
Enable additional ARIA labels and announcements / Bật thêm ARIA labels và thông báo
|
||||
{t('settings.preferences.screenReaderDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -323,7 +345,7 @@ export default function PreferencesPage() {
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Preferences saved successfully / Đã lưu preferences thành công
|
||||
{t('settings.preferences.preferencesSaved')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
@@ -332,7 +354,7 @@ export default function PreferencesPage() {
|
||||
loading={isSaving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save Preferences / Lưu tùy chọn
|
||||
{t('settings.preferences.savePreferences')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,29 +22,32 @@ import {
|
||||
AvatarFallback,
|
||||
} from '@/components/ui/avatar';
|
||||
import { Camera, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: Profile form validation schema using Zod
|
||||
* VI: Schema validation cho form profile sử dụng Zod
|
||||
* EN: Create profile schema with translated messages
|
||||
* VI: Tạo profile schema với thông báo đã dịch
|
||||
*/
|
||||
const profileSchema = z.object({
|
||||
firstName: z
|
||||
.string()
|
||||
.max(255, 'First name must be less than 255 characters / Tên phải ít hơn 255 ký tự')
|
||||
.optional(),
|
||||
lastName: z
|
||||
.string()
|
||||
.max(255, 'Last name must be less than 255 characters / Họ phải ít hơn 255 ký tự')
|
||||
.optional(),
|
||||
phone: z
|
||||
.string()
|
||||
.max(20, 'Phone number must be less than 20 characters / Số điện thoại phải ít hơn 20 ký tự')
|
||||
.optional(),
|
||||
bio: z
|
||||
.string()
|
||||
.max(500, 'Bio must be less than 500 characters / Tiểu sử phải ít hơn 500 ký tự')
|
||||
.optional(),
|
||||
});
|
||||
function createProfileSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
firstName: z
|
||||
.string()
|
||||
.max(255, t('validation.maxLength', { max: 255 }))
|
||||
.optional(),
|
||||
lastName: z
|
||||
.string()
|
||||
.max(255, t('validation.maxLength', { max: 255 }))
|
||||
.optional(),
|
||||
phone: z
|
||||
.string()
|
||||
.max(20, t('validation.maxLength', { max: 20 }))
|
||||
.optional(),
|
||||
bio: z
|
||||
.string()
|
||||
.max(500, t('validation.maxLength', { max: 500 }))
|
||||
.optional(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Type inference from profile schema
|
||||
@@ -66,6 +69,8 @@ type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
* - Error handling
|
||||
*/
|
||||
export default function ProfilePage() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -75,6 +80,10 @@ export default function ProfilePage() {
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// EN: Create schema with translations / VI: Tạo schema với translations
|
||||
const profileSchema = createProfileSchema(t);
|
||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -112,7 +121,7 @@ export default function ProfilePage() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profile / Không thể lấy profile:', error);
|
||||
console.error('Failed to fetch profile:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -128,13 +137,13 @@ export default function ProfilePage() {
|
||||
|
||||
// EN: Validate file type / VI: Kiểm tra loại file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file / Vui lòng chọn file ảnh');
|
||||
alert(t('settings.profile.selectImageFile'));
|
||||
return;
|
||||
}
|
||||
|
||||
// EN: Validate file size (max 5MB) / VI: Kiểm tra kích thước file (tối đa 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Image size must be less than 5MB / Kích thước ảnh phải nhỏ hơn 5MB');
|
||||
alert(t('settings.profile.imageSizeLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,11 +176,11 @@ export default function ProfilePage() {
|
||||
if (response.success && response.data) {
|
||||
setProfile(response.data);
|
||||
setAvatarFile(null);
|
||||
alert('Avatar uploaded successfully / Avatar đã được upload thành công');
|
||||
alert(t('settings.profile.uploadSuccess'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload avatar / Không thể upload avatar:', error);
|
||||
alert('Failed to upload avatar / Không thể upload avatar');
|
||||
console.error('Failed to upload avatar:', error);
|
||||
alert(t('settings.profile.failedToUpload'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -206,7 +215,7 @@ export default function ProfilePage() {
|
||||
setSaveStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile / Không thể cập nhật profile:', error);
|
||||
console.error('Failed to update profile:', error);
|
||||
setSaveStatus('error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -227,7 +236,7 @@ export default function ProfilePage() {
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent"></div>
|
||||
<p className="mt-4 text-sm text-text-tertiary">
|
||||
Loading profile... / Đang tải profile...
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,9 +248,9 @@ export default function ProfilePage() {
|
||||
{/* EN: Profile header card / VI: Card header profile */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Information / Thông tin hồ sơ</CardTitle>
|
||||
<CardTitle>{t('settings.profile.title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Update your profile information and avatar / Cập nhật thông tin hồ sơ và avatar
|
||||
{t('settings.profile.updateInfo', { defaultValue: 'Update your profile information and avatar' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -251,7 +260,7 @@ export default function ProfilePage() {
|
||||
<div className="relative">
|
||||
<Avatar size="xl" className="h-24 w-24">
|
||||
{avatarPreview && (
|
||||
<AvatarImage src={avatarPreview} alt="Avatar / Avatar" />
|
||||
<AvatarImage src={avatarPreview} alt={t('settings.profile.title')} />
|
||||
)}
|
||||
<AvatarFallback>{getUserInitials()}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -259,7 +268,7 @@ export default function ProfilePage() {
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute bottom-0 right-0 flex h-8 w-8 items-center justify-center rounded-full bg-accent-primary text-white shadow-md transition-all hover:brightness-110 hover:scale-110"
|
||||
aria-label="Upload avatar / Upload avatar"
|
||||
aria-label={t('settings.profile.uploadAvatar')}
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -269,21 +278,21 @@ export default function ProfilePage() {
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
aria-label="Avatar file input / Input file avatar"
|
||||
aria-label={t('settings.profile.changeAvatar')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[#FAFAFA]">
|
||||
{profile?.firstName && profile?.lastName
|
||||
? `${profile.firstName} ${profile.lastName}`
|
||||
: user?.email || 'User / Người dùng'}
|
||||
: user?.email || t('common.user', { defaultValue: 'User' })}
|
||||
</h3>
|
||||
<p className="text-sm text-[#A0A0A0]">
|
||||
{user?.email}
|
||||
{(user as any)?.emailVerified && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-[#10B981]">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
<span className="text-xs">Verified / Đã xác thực</span>
|
||||
<span className="text-xs">{t('settings.profile.verified')}</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -295,7 +304,7 @@ export default function ProfilePage() {
|
||||
onClick={handleAvatarUpload}
|
||||
className="mt-2"
|
||||
>
|
||||
Upload Avatar / Upload Avatar
|
||||
{t('settings.profile.uploadAvatar')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -304,39 +313,39 @@ export default function ProfilePage() {
|
||||
{/* EN: Form fields / VI: Các trường form */}
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<Input
|
||||
label="First Name / Tên"
|
||||
placeholder="Enter your first name / Nhập tên của bạn"
|
||||
label={t('settings.profile.firstName')}
|
||||
placeholder={t('settings.profile.enterFirstName', { defaultValue: 'Enter your first name' })}
|
||||
{...register('firstName')}
|
||||
errorMessage={errors.firstName?.message}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Last Name / Họ"
|
||||
placeholder="Enter your last name / Nhập họ của bạn"
|
||||
label={t('settings.profile.lastName')}
|
||||
placeholder={t('settings.profile.enterLastName', { defaultValue: 'Enter your last name' })}
|
||||
{...register('lastName')}
|
||||
errorMessage={errors.lastName?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Email / Email"
|
||||
label={t('settings.profile.email')}
|
||||
type="email"
|
||||
value={user?.email || ''}
|
||||
disabled
|
||||
helperText="Email cannot be changed / Email không thể thay đổi"
|
||||
helperText={t('settings.profile.emailCannotChange', { defaultValue: 'Email cannot be changed' })}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Username / Tên người dùng"
|
||||
value={(user as any)?.username || 'Not set / Chưa đặt'}
|
||||
label={t('settings.profile.username')}
|
||||
value={(user as any)?.username || t('settings.profile.notSet', { defaultValue: 'Not set' })}
|
||||
disabled
|
||||
helperText="Username cannot be changed / Tên người dùng không thể thay đổi"
|
||||
helperText={t('settings.profile.usernameCannotChange', { defaultValue: 'Username cannot be changed' })}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Phone / Số điện thoại"
|
||||
label={t('settings.profile.phone')}
|
||||
type="tel"
|
||||
placeholder="Enter your phone number / Nhập số điện thoại của bạn"
|
||||
placeholder={t('settings.profile.enterPhone', { defaultValue: 'Enter your phone number' })}
|
||||
{...register('phone')}
|
||||
errorMessage={errors.phone?.message}
|
||||
/>
|
||||
@@ -346,12 +355,12 @@ export default function ProfilePage() {
|
||||
htmlFor="bio"
|
||||
className="block text-sm font-medium text-text-secondary mb-2"
|
||||
>
|
||||
Bio / Tiểu sử
|
||||
{t('settings.profile.bio')}
|
||||
</label>
|
||||
<textarea
|
||||
id="bio"
|
||||
rows={4}
|
||||
placeholder="Tell us about yourself / Hãy cho chúng tôi biết về bạn"
|
||||
placeholder={t('settings.profile.bioPlaceholder', { defaultValue: 'Tell us about yourself' })}
|
||||
{...register('bio')}
|
||||
className="flex w-full rounded-md border border-border-primary bg-bg-secondary px-3 py-2 text-base text-text-primary placeholder:text-text-tertiary transition-all duration-[150ms] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:border-accent-primary focus-visible:ring-accent-primary focus-visible:shadow-[0_0_20px_rgba(59,130,246,0.3)] disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-bg-tertiary"
|
||||
/>
|
||||
@@ -368,7 +377,7 @@ export default function ProfilePage() {
|
||||
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<span>
|
||||
Profile updated successfully / Profile đã được cập nhật thành công
|
||||
{t('settings.profile.changesSaved')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -377,7 +386,7 @@ export default function ProfilePage() {
|
||||
<div className="rounded-lg bg-accent-error/10 border border-accent-error p-3 flex items-center gap-2 text-sm text-accent-error">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span>
|
||||
Failed to update profile / Không thể cập nhật profile
|
||||
{t('settings.profile.failedToUpdate')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -389,7 +398,7 @@ export default function ProfilePage() {
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
Save Changes / Lưu thay đổi
|
||||
{t('settings.profile.saveChanges')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
||||
@@ -10,42 +10,41 @@ import { Input } from '@/components/ui/input';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Shield, Smartphone, Trash2, LogOut, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: Change password form validation schema using Zod
|
||||
* VI: Schema validation cho form đổi mật khẩu sử dụng Zod
|
||||
* EN: Create change password schema with translated messages
|
||||
* VI: Tạo change password schema với thông báo đã dịch
|
||||
*/
|
||||
const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required / Mật khẩu hiện tại là bắt buộc'),
|
||||
newPassword: z
|
||||
function createChangePasswordSchema(t: (key: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, t('settings.security.currentPasswordRequired')),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(1, t('validation.password'))
|
||||
.min(8, t('validation.passwordMin')),
|
||||
confirmPassword: z.string().min(1, t('validation.passwordConfirmRequired')),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t('validation.passwordConfirm'),
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Create TOTP verify schema with translated messages
|
||||
* VI: Tạo TOTP verify schema với thông báo đã dịch
|
||||
*/
|
||||
function createTotpVerifySchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
token: z
|
||||
.string()
|
||||
.min(1, 'New password is required / Mật khẩu mới 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ự'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password / Vui lòng xác nhận mật khẩu'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords do not match / Mật khẩu không khớp',
|
||||
path: ['confirmPassword'],
|
||||
.min(6, t('settings.security.codeMustBe6Digits'))
|
||||
.max(6, t('settings.security.codeMustBe6Digits'))
|
||||
.regex(/^\d{6}$/, t('settings.security.codeMustBe6Digits')),
|
||||
});
|
||||
|
||||
/**
|
||||
* EN: Type inference from change password schema
|
||||
* VI: Suy luận kiểu từ change password schema
|
||||
*/
|
||||
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
|
||||
|
||||
/**
|
||||
* EN: TOTP verification schema
|
||||
* VI: Schema xác thực TOTP
|
||||
*/
|
||||
const totpVerifySchema = z.object({
|
||||
token: z
|
||||
.string()
|
||||
.min(6, 'Code must be 6 digits / Mã phải có 6 chữ số')
|
||||
.max(6, 'Code must be 6 digits / Mã phải có 6 chữ số')
|
||||
.regex(/^\d{6}$/, 'Code must be 6 digits / Mã phải có 6 chữ số'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Type inference from TOTP verify schema
|
||||
@@ -70,8 +69,8 @@ interface Session {
|
||||
* 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();
|
||||
@@ -79,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,
|
||||
@@ -196,17 +195,17 @@ export default function SecurityPage() {
|
||||
try {
|
||||
const response = await authApi.changePassword(data.currentPassword, data.newPassword);
|
||||
if (response.success) {
|
||||
setPasswordSuccess('Password changed successfully / Mật khẩu đã được thay đổi thành công');
|
||||
setPasswordSuccess(t('settings.security.passwordChanged'));
|
||||
resetPasswordForm();
|
||||
// EN: Clear success message after 5 seconds / VI: Xóa thông báo thành công sau 5 giây
|
||||
setTimeout(() => setPasswordSuccess(''), 5000);
|
||||
} else {
|
||||
setPasswordError(
|
||||
response.error?.message || 'Failed to change password / Không thể thay đổi mật khẩu'
|
||||
response.error?.message || t('settings.security.passwordChangeFailed')
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setPasswordError(error.message || 'Failed to change password / Không thể thay đổi mật khẩu');
|
||||
setPasswordError(error.message || t('settings.security.passwordChangeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,11 +224,11 @@ export default function SecurityPage() {
|
||||
setShowQRDialog(true);
|
||||
} else {
|
||||
setMfaError(
|
||||
response.error?.message || 'Failed to enable 2FA / Không thể bật 2FA'
|
||||
response.error?.message || t('settings.security.enable2FAFailed')
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setMfaError(error.message || 'Failed to enable 2FA / Không thể bật 2FA');
|
||||
setMfaError(error.message || t('settings.security.enable2FAFailed'));
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
@@ -245,7 +244,7 @@ export default function SecurityPage() {
|
||||
try {
|
||||
const response = await authApi.verifyAndEnableTOTP(totpSecret, data.token);
|
||||
if (response.success) {
|
||||
setMfaSuccess('2FA enabled successfully / 2FA đã được bật thành công');
|
||||
setMfaSuccess(t('settings.security.2FAEnabled'));
|
||||
setShowQRDialog(false);
|
||||
setMfaEnabled(true);
|
||||
resetTOTPForm();
|
||||
@@ -255,11 +254,11 @@ export default function SecurityPage() {
|
||||
setTimeout(() => setMfaSuccess(''), 5000);
|
||||
} else {
|
||||
setMfaError(
|
||||
response.error?.message || 'Invalid verification code / Mã xác thực không hợp lệ'
|
||||
response.error?.message || t('settings.security.invalidVerificationCode')
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setMfaError(error.message || 'Invalid verification code / Mã xác thực không hợp lệ');
|
||||
setMfaError(error.message || t('settings.security.invalidVerificationCode'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -268,7 +267,7 @@ export default function SecurityPage() {
|
||||
* VI: Xử lý tắt 2FA
|
||||
*/
|
||||
const handleDisable2FA = async () => {
|
||||
if (!confirm('Are you sure you want to disable 2FA? / Bạn có chắc chắn muốn tắt 2FA?')) {
|
||||
if (!confirm(t('settings.security.confirmDisable2FA'))) {
|
||||
return;
|
||||
}
|
||||
setMfaLoading(true);
|
||||
@@ -277,16 +276,16 @@ export default function SecurityPage() {
|
||||
const response = await authApi.disableMFA();
|
||||
if (response.success) {
|
||||
setMfaEnabled(false);
|
||||
setMfaSuccess('2FA disabled successfully / 2FA đã được tắt thành công');
|
||||
setMfaSuccess(t('settings.security.2FADisabled'));
|
||||
// EN: Clear success message after 5 seconds / VI: Xóa thông báo thành công sau 5 giây
|
||||
setTimeout(() => setMfaSuccess(''), 5000);
|
||||
} else {
|
||||
setMfaError(
|
||||
response.error?.message || 'Failed to disable 2FA / Không thể tắt 2FA'
|
||||
response.error?.message || t('settings.security.disable2FAFailed')
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setMfaError(error.message || 'Failed to disable 2FA / Không thể tắt 2FA');
|
||||
setMfaError(error.message || t('settings.security.disable2FAFailed'));
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
@@ -316,7 +315,7 @@ export default function SecurityPage() {
|
||||
* VI: Xử lý thu hồi tất cả sessions
|
||||
*/
|
||||
const handleRevokeAllSessions = async () => {
|
||||
if (!confirm('Are you sure you want to revoke all other sessions? / Bạn có chắc chắn muốn thu hồi tất cả các session khác?')) {
|
||||
if (!confirm(t('settings.security.confirmRevokeAll', { defaultValue: 'Are you sure you want to revoke all other sessions?' }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -335,10 +334,10 @@ export default function SecurityPage() {
|
||||
{/* EN: Page header / VI: Header trang */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-text-primary">
|
||||
Security / Bảo mật
|
||||
{t('settings.security.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
Manage your account security settings / Quản lý cài đặt bảo mật tài khoản
|
||||
{t('settings.security.manageSettings', { defaultValue: 'Manage your account security settings' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -347,10 +346,10 @@ export default function SecurityPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-[#3B82F6]" />
|
||||
Change Password / Đổi mật khẩu
|
||||
{t('settings.security.changePassword')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure / Cập nhật mật khẩu để giữ tài khoản an toàn
|
||||
{t('settings.security.updatePasswordDesc', { defaultValue: 'Update your password to keep your account secure' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handlePasswordSubmit(onPasswordSubmit)}>
|
||||
@@ -380,8 +379,8 @@ export default function SecurityPage() {
|
||||
{/* EN: Current password input / VI: Input mật khẩu hiện tại */}
|
||||
<Input
|
||||
type="password"
|
||||
label="Current Password / Mật khẩu hiện tại"
|
||||
placeholder="Enter current password / Nhập mật khẩu hiện tại"
|
||||
label={t('settings.security.currentPassword')}
|
||||
placeholder={t('settings.security.enterCurrentPassword', { defaultValue: 'Enter current password' })}
|
||||
{...registerPassword('currentPassword')}
|
||||
errorMessage={passwordErrors.currentPassword?.message}
|
||||
validationState={passwordErrors.currentPassword ? 'error' : 'default'}
|
||||
@@ -390,19 +389,19 @@ export default function SecurityPage() {
|
||||
{/* EN: New password input / VI: Input mật khẩu mới */}
|
||||
<Input
|
||||
type="password"
|
||||
label="New Password / Mật khẩu mới"
|
||||
placeholder="Enter new password / Nhập mật khẩu mới"
|
||||
label={t('settings.security.newPassword')}
|
||||
placeholder={t('settings.security.enterNewPassword', { defaultValue: 'Enter new password' })}
|
||||
{...registerPassword('newPassword')}
|
||||
errorMessage={passwordErrors.newPassword?.message}
|
||||
validationState={passwordErrors.newPassword ? 'error' : 'default'}
|
||||
helperText="At least 8 characters / Ít nhất 8 ký tự"
|
||||
helperText={t('validation.passwordMin')}
|
||||
/>
|
||||
|
||||
{/* EN: Confirm password input / VI: Input xác nhận mật khẩu */}
|
||||
<Input
|
||||
type="password"
|
||||
label="Confirm Password / Xác nhận mật khẩu"
|
||||
placeholder="Confirm new password / Xác nhận mật khẩu mới"
|
||||
label={t('settings.security.confirmPassword')}
|
||||
placeholder={t('settings.security.confirmNewPassword', { defaultValue: 'Confirm new password' })}
|
||||
{...registerPassword('confirmPassword')}
|
||||
errorMessage={passwordErrors.confirmPassword?.message}
|
||||
validationState={passwordErrors.confirmPassword ? 'error' : 'default'}
|
||||
@@ -410,7 +409,7 @@ export default function SecurityPage() {
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" loading={isPasswordSubmitting}>
|
||||
Update Password / Cập nhật mật khẩu
|
||||
{t('settings.security.updatePassword', { defaultValue: 'Update Password' })}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
@@ -421,10 +420,10 @@ export default function SecurityPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Smartphone className="h-5 w-5 text-[#8B5CF6]" />
|
||||
Two-Factor Authentication (2FA) / Xác thực hai yếu tố (2FA)
|
||||
{t('settings.security.twoFactorAuth')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add an extra layer of security to your account / Thêm một lớp bảo mật bổ sung cho tài khoản
|
||||
{t('settings.security.twoFactorDesc', { defaultValue: 'Add an extra layer of security to your account' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -454,12 +453,12 @@ export default function SecurityPage() {
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
2FA Status / Trạng thái 2FA
|
||||
{t('settings.security.2FAStatus', { defaultValue: '2FA Status' })}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">
|
||||
{mfaEnabled
|
||||
? 'Enabled / Đã bật'
|
||||
: 'Disabled / Đã tắt'}
|
||||
? t('settings.security.enabled', { defaultValue: 'Enabled' })
|
||||
: t('settings.security.disabled', { defaultValue: 'Disabled' })}
|
||||
</p>
|
||||
</div>
|
||||
{mfaEnabled ? (
|
||||
@@ -469,7 +468,7 @@ export default function SecurityPage() {
|
||||
onClick={handleDisable2FA}
|
||||
loading={mfaLoading}
|
||||
>
|
||||
Disable 2FA / Tắt 2FA
|
||||
{t('settings.security.disable2FA')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -478,7 +477,7 @@ export default function SecurityPage() {
|
||||
onClick={handleEnable2FA}
|
||||
loading={mfaLoading}
|
||||
>
|
||||
Enable 2FA / Bật 2FA
|
||||
{t('settings.security.enable2FA')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -487,10 +486,7 @@ export default function SecurityPage() {
|
||||
{!mfaEnabled && (
|
||||
<div className="p-4 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Two-factor authentication adds an extra layer of security. When enabled, you'll need to enter a code from your authenticator app in addition to your password when signing in.
|
||||
</p>
|
||||
<p className="text-sm text-[#E0E0E0] mt-2">
|
||||
Xác thực hai yếu tố thêm một lớp bảo mật bổ sung. Khi được bật, bạn sẽ cần nhập mã từ ứng dụng xác thực của mình ngoài mật khẩu khi đăng nhập.
|
||||
{t('settings.security.twoFactorInstructions', { defaultValue: 'Two-factor authentication adds an extra layer of security. When enabled, you\'ll need to enter a code from your authenticator app in addition to your password when signing in.' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -502,20 +498,20 @@ export default function SecurityPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LogOut className="h-5 w-5 text-accent-info" />
|
||||
Active Sessions / Sessions đang hoạt động
|
||||
{t('settings.security.activeSessions')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage devices that are signed in to your account / Quản lý các thiết bị đã đăng nhập vào tài khoản
|
||||
{t('settings.security.manageDevices', { defaultValue: 'Manage devices that are signed in to your account' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{sessionsLoading ? (
|
||||
<div className="text-center py-8 text-[#A0A0A0]">
|
||||
Loading sessions... / Đang tải sessions...
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-8 text-[#A0A0A0]">
|
||||
No active sessions / Không có sessions đang hoạt động
|
||||
{t('settings.security.noActiveSessions')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -530,20 +526,20 @@ export default function SecurityPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="h-4 w-4 text-[#A0A0A0]" />
|
||||
<p className="text-sm font-medium text-[#FAFAFA]">
|
||||
{session.deviceName || 'Unknown Device / Thiết bị không xác định'}
|
||||
{session.deviceName || t('settings.security.unknownDevice', { defaultValue: 'Unknown Device' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{session.ipAddress && (
|
||||
<p className="text-xs text-[#A0A0A0]">
|
||||
IP: {session.ipAddress}
|
||||
{t('settings.security.ipAddress')}: {session.ipAddress}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-[#A0A0A0]">
|
||||
Last activity: {formatRelativeTime(session.lastActivityAt)}
|
||||
{t('settings.security.lastActivity')}: {formatRelativeTime(session.lastActivityAt, t, locale)}
|
||||
</p>
|
||||
<p className="text-xs text-[#A0A0A0]">
|
||||
Created: {formatRelativeTime(session.createdAt)}
|
||||
{t('settings.security.created')}: {formatRelativeTime(session.createdAt, t, locale)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -568,7 +564,7 @@ export default function SecurityPage() {
|
||||
size="sm"
|
||||
onClick={handleRevokeAllSessions}
|
||||
>
|
||||
Revoke All Other Sessions / Thu hồi tất cả sessions khác
|
||||
{t('settings.security.revokeAllOtherSessions', { defaultValue: 'Revoke All Other Sessions' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -581,9 +577,9 @@ export default function SecurityPage() {
|
||||
<Dialog open={showQRDialog} onOpenChange={setShowQRDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Up Two-Factor Authentication / Thiết lập Xác thực Hai yếu tố</DialogTitle>
|
||||
<DialogTitle>{t('settings.security.setup2FA', { defaultValue: 'Set Up Two-Factor Authentication' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan this QR code with your authenticator app / Quét mã QR này bằng ứng dụng xác thực của bạn
|
||||
{t('settings.security.scanQRCode', { defaultValue: 'Scan this QR code with your authenticator app' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -594,7 +590,7 @@ export default function SecurityPage() {
|
||||
{/* EN: Using img for data URLs (QR codes), Next.js Image doesn't support data URLs / VI: Sử dụng img cho data URLs (QR codes), Next.js Image không hỗ trợ data URLs */}
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="QR Code for two-factor authentication / Mã QR cho xác thực hai yếu tố"
|
||||
alt={t('settings.security.qrCodeAlt', { defaultValue: 'QR Code for two-factor authentication' })}
|
||||
className="w-64 h-64"
|
||||
loading="lazy"
|
||||
/>
|
||||
@@ -605,7 +601,7 @@ export default function SecurityPage() {
|
||||
{totpSecret && (
|
||||
<div className="p-3 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<p className="text-xs text-text-tertiary mb-1">
|
||||
Can't scan? Enter this code manually / Không thể quét? Nhập mã này theo cách thủ công
|
||||
{t('settings.security.cantScan', { defaultValue: 'Can\'t scan? Enter this code manually' })}
|
||||
</p>
|
||||
<p className="text-sm font-mono text-text-primary break-all">
|
||||
{totpSecret}
|
||||
@@ -628,13 +624,13 @@ export default function SecurityPage() {
|
||||
<form onSubmit={handleTOTPSubmit(onTOTPSubmit)} className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
label="Verification Code / Mã xác thực"
|
||||
placeholder="Enter 6-digit code / Nhập mã 6 chữ số"
|
||||
label={t('settings.security.verificationCode', { defaultValue: 'Verification Code' })}
|
||||
placeholder={t('settings.security.enter6DigitCode', { defaultValue: 'Enter 6-digit code' })}
|
||||
maxLength={6}
|
||||
{...registerTOTP('token')}
|
||||
errorMessage={totpErrors.token?.message}
|
||||
validationState={totpErrors.token ? 'error' : 'default'}
|
||||
helperText="Enter the 6-digit code from your authenticator app / Nhập mã 6 chữ số từ ứng dụng xác thực của bạn"
|
||||
helperText={t('settings.security.enterCodeFromApp', { defaultValue: 'Enter the 6-digit code from your authenticator app' })}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -649,10 +645,10 @@ export default function SecurityPage() {
|
||||
setMfaError('');
|
||||
}}
|
||||
>
|
||||
Cancel / Hủy
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" loading={isTOTPSubmitting}>
|
||||
Verify & Enable / Xác thực & Bật
|
||||
{t('settings.security.verifyAndEnable', { defaultValue: 'Verify & Enable' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { ThemeProvider } from '../contexts/theme-context';
|
||||
import { QueryProvider } from '../providers/query-provider';
|
||||
import { I18nProvider } from '../providers/i18n-provider';
|
||||
import { SkipToContent } from '../components/accessibility/skip-to-content';
|
||||
|
||||
/**
|
||||
@@ -25,14 +26,16 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
// EN: Root HTML structure with English language
|
||||
// VI: Cấu trúc HTML gốc với ngôn ngữ tiếng Anh
|
||||
// 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>
|
||||
<SkipToContent />
|
||||
<QueryProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryProvider>
|
||||
<I18nProvider>
|
||||
<QueryProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: Home page component - main application entry point
|
||||
* VI: Component trang chủ - điểm vào chính của ứng dụng
|
||||
*/
|
||||
export default function Home() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
// EN: Get authentication state from store
|
||||
// VI: Lấy trạng thái xác thực từ store
|
||||
const { user, isAuthenticated, isLoading, fetchUser } = useAuthStore();
|
||||
@@ -23,26 +27,26 @@ export default function Home() {
|
||||
// EN: Show loading state while checking authentication
|
||||
// VI: Hiển thị trạng thái loading trong khi kiểm tra xác thực
|
||||
if (isLoading) {
|
||||
return <div className="p-8">Loading... / Đang tải...</div>;
|
||||
return <div className="p-8">{t('common.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
// EN: Main content area with responsive padding
|
||||
// VI: Khu vực nội dung chính với padding responsive
|
||||
<main className="min-h-screen p-8">
|
||||
<h1 className="text-4xl font-bold mb-4">GoodGo Platform / Nền tảng GoodGo</h1>
|
||||
<h1 className="text-4xl font-bold mb-4">{t('home.title')}</h1>
|
||||
|
||||
{/* EN: Conditional rendering based on authentication status / VI: Render có điều kiện dựa trên trạng thái xác thực */}
|
||||
{isAuthenticated && user ? (
|
||||
// EN: Authenticated user welcome message / VI: Thông báo chào mừng người dùng đã xác thực
|
||||
<div>
|
||||
<p>Welcome, {user.email}! / Chào mừng, {user.email}!</p>
|
||||
<p>Role: {user.role} / Vai trò: {user.role}</p>
|
||||
<p>{t('home.welcome', { email: user.email })}</p>
|
||||
<p>{t('home.role', { role: user.role })}</p>
|
||||
</div>
|
||||
) : (
|
||||
// EN: Login prompt for unauthenticated users / VI: Nhắc đăng nhập cho người dùng chưa xác thực
|
||||
<div>
|
||||
<p>Please log in to continue. / Vui lòng đăng nhập để tiếp tục.</p>
|
||||
<p>{t('home.pleaseLogin')}</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: ChatInput component props interface
|
||||
@@ -85,13 +86,16 @@ export function ChatInput({
|
||||
onChange,
|
||||
onSend,
|
||||
onAttachFile,
|
||||
placeholder = 'Type your message... / Nhập tin nhắn...',
|
||||
placeholder,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
maxHeight = 200,
|
||||
minHeight = 44,
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
const defaultPlaceholder = placeholder || t('chat.typeMessage');
|
||||
// EN: Reference to textarea element for auto-resize / VI: Reference đến element textarea cho auto-resize
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -178,7 +182,7 @@ export function ChatInput({
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
aria-label="Attach file / Đính kèm file"
|
||||
aria-label={t('chat.attachFile')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
@@ -205,7 +209,7 @@ export function ChatInput({
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
placeholder={defaultPlaceholder}
|
||||
rows={1}
|
||||
className={cn(
|
||||
'w-full',
|
||||
@@ -230,12 +234,12 @@ export function ChatInput({
|
||||
minHeight: `${minHeight}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
}}
|
||||
aria-label="Message input / Ô nhập tin nhắn"
|
||||
aria-label={t('chat.messageInput')}
|
||||
aria-describedby="chat-input-help"
|
||||
/>
|
||||
{/* EN: Hidden helper text for screen readers / VI: Text hướng dẫn ẩn cho screen readers */}
|
||||
<span id="chat-input-help" className="sr-only">
|
||||
Press Enter to send, Shift+Enter for new line / Nhấn Enter để gửi, Shift+Enter để xuống dòng
|
||||
{t('chat.sendHelp')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -256,7 +260,7 @@ export function ChatInput({
|
||||
// EN: Adjust button styles for square shape / VI: Điều chỉnh styles cho button hình vuông
|
||||
!canSend && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
aria-label="Send message / Gửi tin nhắn"
|
||||
aria-label={t('chat.send')}
|
||||
>
|
||||
{!loading && (
|
||||
<svg
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: Chat layout component props interface
|
||||
@@ -95,6 +96,9 @@ export function ChatLayout({
|
||||
onRightPanelToggle: _onRightPanelToggle,
|
||||
className,
|
||||
}: ChatLayoutProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
// EN: Mobile: Hide sidebar by default / VI: Mobile: Ẩn sidebar mặc định
|
||||
const [mobileSidebarVisible, setMobileSidebarVisible] = React.useState(false);
|
||||
const [touchStart, setTouchStart] = React.useState<number | null>(null);
|
||||
@@ -196,7 +200,7 @@ export function ChatLayout({
|
||||
'shadow-lg',
|
||||
'min-w-[44px] min-h-[44px]'
|
||||
)}
|
||||
aria-label={isSidebarVisible ? 'Close sidebar / Đóng sidebar' : 'Open sidebar / Mở sidebar'}
|
||||
aria-label={isSidebarVisible ? t('chat.closeSidebar', { defaultValue: 'Close sidebar' }) : t('chat.openSidebar', { defaultValue: 'Open sidebar' })}
|
||||
>
|
||||
{isSidebarVisible ? (
|
||||
<X className="h-5 w-5" />
|
||||
@@ -222,7 +226,7 @@ export function ChatLayout({
|
||||
'md:block lg:block',
|
||||
!isSidebarVisible && 'md:hidden'
|
||||
)}
|
||||
aria-label="Conversation sidebar / Sidebar cuộc trò chuyện"
|
||||
aria-label={t('chat.conversationSidebar', { defaultValue: 'Conversation sidebar' })}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
@@ -240,7 +244,7 @@ export function ChatLayout({
|
||||
'w-full'
|
||||
)}
|
||||
role="main"
|
||||
aria-label="Main chat area / Khu vực chat chính"
|
||||
aria-label={t('chat.mainChatArea', { defaultValue: 'Main chat area' })}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -271,7 +275,7 @@ export function ChatLayout({
|
||||
// EN: Show/hide based on rightPanelVisible prop / VI: Hiện/ẩn dựa trên prop rightPanelVisible
|
||||
rightPanelVisible ? 'lg:flex' : 'lg:hidden'
|
||||
)}
|
||||
aria-label="Conversation settings panel / Panel cài đặt cuộc trò chuyện"
|
||||
aria-label={t('chat.conversationSettingsPanel', { defaultValue: 'Conversation settings panel' })}
|
||||
>
|
||||
{rightPanel}
|
||||
</aside>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: Conversation interface
|
||||
@@ -64,6 +65,8 @@ export function ConversationSidebar({
|
||||
onNewChat,
|
||||
className,
|
||||
}: ConversationSidebarProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t, locale } = useTranslation();
|
||||
// EN: Get current user from auth store / VI: Lấy user hiện tại từ auth store
|
||||
const { user } = useAuthStore();
|
||||
|
||||
@@ -92,11 +95,11 @@ export function ConversationSidebar({
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now / Vừa xong';
|
||||
if (diffMins < 1) return t('chat.justNow');
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return date.toLocaleDateString(locale === 'vi' ? 'vi-VN' : 'en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
// EN: Get user initials for avatar fallback / VI: Lấy initials của user cho avatar fallback
|
||||
@@ -124,7 +127,7 @@ export function ConversationSidebar({
|
||||
size="md"
|
||||
className="w-full"
|
||||
onClick={onNewChat}
|
||||
aria-label="New Chat / Cuộc trò chuyện mới"
|
||||
aria-label={t('chat.newChat')}
|
||||
>
|
||||
{/* EN: Plus icon / VI: Icon dấu cộng */}
|
||||
<svg
|
||||
@@ -142,30 +145,30 @@ export function ConversationSidebar({
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
New Chat / Cuộc trò chuyện mới
|
||||
{t('chat.newChat')}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* EN: Search section / VI: Phần tìm kiếm */}
|
||||
<nav className="p-4 border-b border-border-primary" role="search" aria-label="Search conversations / Tìm kiếm cuộc trò chuyện">
|
||||
<nav className="p-4 border-b border-border-primary" role="search" aria-label={t('chat.searchConversations')}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search conversations... / Tìm kiếm..."
|
||||
placeholder={t('chat.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
aria-label="Search conversations / Tìm kiếm cuộc trò chuyện"
|
||||
aria-label={t('chat.searchConversations')}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{/* EN: Conversations list (scrollable) / VI: Danh sách conversations (có thể scroll) */}
|
||||
<nav className="flex-1 overflow-y-auto" role="navigation" aria-label="Conversation list / Danh sách cuộc trò chuyện">
|
||||
<nav className="flex-1 overflow-y-auto" role="navigation" aria-label={t('chat.conversationList')}>
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-text-tertiary text-sm">
|
||||
{searchQuery
|
||||
? 'No conversations found / Không tìm thấy cuộc trò chuyện'
|
||||
: 'No conversations yet / Chưa có cuộc trò chuyện nào'}
|
||||
? t('chat.noConversationsFound')
|
||||
: t('chat.noConversations')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -186,7 +189,7 @@ export function ConversationSidebar({
|
||||
? 'bg-bg-tertiary border-l-3 border-accent-primary'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
aria-label={`Conversation: ${conversation.title} / Cuộc trò chuyện: ${conversation.title}`}
|
||||
aria-label={`${t('chat.conversation')}: ${conversation.title}`}
|
||||
>
|
||||
{/* EN: Conversation title / VI: Tiêu đề conversation */}
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
@@ -230,7 +233,7 @@ export function ConversationSidebar({
|
||||
{/* EN: Settings icon / VI: Icon cài đặt */}
|
||||
<button
|
||||
className="p-2 rounded-md hover:bg-bg-tertiary transition-colors"
|
||||
aria-label="Settings / Cài đặt"
|
||||
aria-label={t('settings.title')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: Message role type
|
||||
@@ -87,13 +88,16 @@ export function MessageActionsMenu({
|
||||
className,
|
||||
children,
|
||||
}: MessageActionsMenuProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
// EN: Copy to clipboard handler / VI: Handler copy vào clipboard
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(messageContent);
|
||||
onCopy?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to copy message / Không thể copy message:', error);
|
||||
console.error('Failed to copy message:', error);
|
||||
}
|
||||
}, [messageContent, onCopy]);
|
||||
|
||||
@@ -103,7 +107,7 @@ export function MessageActionsMenu({
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
text: messageContent,
|
||||
title: 'Shared Message / Tin nhắn được chia sẻ',
|
||||
title: t('chat.shareMessage'),
|
||||
});
|
||||
onShare?.();
|
||||
} else {
|
||||
@@ -114,7 +118,7 @@ export function MessageActionsMenu({
|
||||
} catch (error) {
|
||||
// EN: User cancelled share or error occurred / VI: User hủy share hoặc có lỗi
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error('Failed to share message / Không thể share message:', error);
|
||||
console.error('Failed to share message:', error);
|
||||
}
|
||||
}
|
||||
}, [messageContent, handleCopy, onShare]);
|
||||
@@ -134,7 +138,7 @@ export function MessageActionsMenu({
|
||||
'text-text-tertiary hover:text-text-primary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2'
|
||||
)}
|
||||
aria-label="Message actions / Các hành động cho message"
|
||||
aria-label={t('chat.messageActions', { defaultValue: 'Message actions' })}
|
||||
>
|
||||
{/* EN: More options icon (three dots) / VI: Icon thêm tùy chọn (ba chấm) */}
|
||||
<svg
|
||||
@@ -175,7 +179,7 @@ export function MessageActionsMenu({
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2h8c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
Copy / Sao chép
|
||||
{t('chat.copy')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* EN: Edit action - only for user messages / VI: Action Edit - chỉ cho user messages */}
|
||||
@@ -197,7 +201,7 @@ export function MessageActionsMenu({
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit / Chỉnh sửa
|
||||
{t('chat.edit')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -222,7 +226,7 @@ export function MessageActionsMenu({
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</svg>
|
||||
Regenerate / Tạo lại
|
||||
{t('chat.regenerate')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -250,7 +254,7 @@ export function MessageActionsMenu({
|
||||
<path d="M7 10v12" />
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2h0a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
</svg>
|
||||
Like / Thích
|
||||
{t('chat.like')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -275,7 +279,7 @@ export function MessageActionsMenu({
|
||||
<path d="M17 14V2" />
|
||||
<path d="M9 18.12 10 14H4.17a2 2 0 0 0-1.92 2.56l2.33 8A2 2 0 0 0 6.5 22H20a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-2.76a2 2 0 0 0-1.79-1.11L12 2h0a3.13 3.13 0 0 0-3 3.88Z" />
|
||||
</svg>
|
||||
Dislike / Không thích
|
||||
{t('chat.dislike')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -301,7 +305,7 @@ export function MessageActionsMenu({
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" x2="12" y1="2" y2="15" />
|
||||
</svg>
|
||||
Share / Chia sẻ
|
||||
{t('chat.share')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -331,7 +335,7 @@ export function MessageActionsMenu({
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
Delete / Xóa
|
||||
{t('chat.delete')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: Message sender type
|
||||
@@ -97,7 +98,7 @@ export interface MessageBubbleProps {
|
||||
* EN: Format timestamp to readable string
|
||||
* VI: Format timestamp thành chuỗi dễ đọc
|
||||
*/
|
||||
function formatTimestamp(timestamp: Date | string): string {
|
||||
function formatTimestamp(timestamp: Date | string, t: (key: string, values?: any) => string, locale: string): string {
|
||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
@@ -106,15 +107,15 @@ function formatTimestamp(timestamp: Date | string): string {
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) {
|
||||
return 'Just now / Vừa xong';
|
||||
return t('chat.justNow');
|
||||
} else if (diffMins < 60) {
|
||||
return `${diffMins}m ago / ${diffMins} phút trước`;
|
||||
return t('chat.minutesAgo', { minutes: diffMins });
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}h ago / ${diffHours} giờ trước`;
|
||||
return t('chat.hoursAgo', { hours: diffHours });
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}d ago / ${diffDays} ngày trước`;
|
||||
return t('chat.daysAgo', { days: diffDays });
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
return date.toLocaleDateString(locale === 'vi' ? 'vi-VN' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
@@ -328,6 +329,9 @@ export function MessageBubble({
|
||||
isDisliked = false,
|
||||
className,
|
||||
}: MessageBubbleProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t, locale } = useTranslation();
|
||||
|
||||
// EN: System messages - centered, simple text / VI: Tin nhắn hệ thống - căn giữa, text đơn giản
|
||||
if (sender === 'system') {
|
||||
return (
|
||||
@@ -367,10 +371,10 @@ export function MessageBubble({
|
||||
{authorAvatar && (
|
||||
<AvatarImage
|
||||
src={authorAvatar}
|
||||
alt={authorName ? `Avatar of ${authorName} / Avatar của ${authorName}` : 'AI assistant avatar / Avatar trợ lý AI'}
|
||||
alt={authorName ? t('chat.avatarOf', { name: authorName }) : t('chat.aiAssistantAvatar')}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback aria-label="AI assistant / Trợ lý AI">AI</AvatarFallback>
|
||||
<AvatarFallback aria-label={t('chat.aiAssistant')}>AI</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
@@ -405,7 +409,7 @@ export function MessageBubble({
|
||||
)}
|
||||
{timestamp && (
|
||||
<span className="text-xs text-chat-timestamp">
|
||||
{formatTimestamp(timestamp)}
|
||||
{formatTimestamp(timestamp, t, locale)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -444,8 +448,8 @@ export function MessageBubble({
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label="Copy message / Sao chép tin nhắn"
|
||||
title="Copy / Sao chép"
|
||||
aria-label={t('chat.copyMessage')}
|
||||
title={t('chat.copy')}
|
||||
>
|
||||
<CopyIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
@@ -456,8 +460,8 @@ export function MessageBubble({
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label="Edit message / Chỉnh sửa tin nhắn"
|
||||
title="Edit / Chỉnh sửa"
|
||||
aria-label={t('chat.editMessage')}
|
||||
title={t('chat.edit')}
|
||||
>
|
||||
<EditIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
@@ -468,8 +472,8 @@ export function MessageBubble({
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label="Delete message / Xóa tin nhắn"
|
||||
title="Delete / Xóa"
|
||||
aria-label={t('chat.deleteMessage')}
|
||||
title={t('chat.delete')}
|
||||
>
|
||||
<DeleteIcon className="w-4 h-4 text-accent-error" />
|
||||
</button>
|
||||
@@ -480,8 +484,8 @@ export function MessageBubble({
|
||||
<button
|
||||
onClick={onRegenerate}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label="Regenerate response / Tạo lại phản hồi"
|
||||
title="Regenerate / Tạo lại"
|
||||
aria-label={t('chat.regenerateResponse')}
|
||||
title={t('chat.regenerate')}
|
||||
>
|
||||
<RegenerateIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
@@ -495,8 +499,8 @@ export function MessageBubble({
|
||||
'p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center',
|
||||
isLiked && 'text-accent-success'
|
||||
)}
|
||||
aria-label="Like message / Thích tin nhắn"
|
||||
title="Like / Thích"
|
||||
aria-label={t('chat.likeMessage')}
|
||||
title={t('chat.like')}
|
||||
>
|
||||
<LikeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -510,8 +514,8 @@ export function MessageBubble({
|
||||
'p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center',
|
||||
isDisliked && 'text-accent-error'
|
||||
)}
|
||||
aria-label="Dislike message / Không thích tin nhắn"
|
||||
title="Dislike / Không thích"
|
||||
aria-label={t('chat.dislikeMessage')}
|
||||
title={t('chat.dislike')}
|
||||
>
|
||||
<DislikeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -522,8 +526,8 @@ export function MessageBubble({
|
||||
<button
|
||||
onClick={onShare}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label="Share message / Chia sẻ tin nhắn"
|
||||
title="Share / Chia sẻ"
|
||||
aria-label={t('chat.shareMessage')}
|
||||
title={t('chat.share')}
|
||||
>
|
||||
<ShareIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
@@ -539,10 +543,10 @@ export function MessageBubble({
|
||||
{authorAvatar && (
|
||||
<AvatarImage
|
||||
src={authorAvatar}
|
||||
alt={authorName ? `Avatar of ${authorName} / Avatar của ${authorName}` : 'User avatar / Avatar người dùng'}
|
||||
alt={authorName ? t('chat.avatarOf', { name: authorName }) : t('chat.userAvatar')}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback aria-label={authorName ? `Avatar of ${authorName} / Avatar của ${authorName}` : 'User avatar / Avatar người dùng'}>
|
||||
<AvatarFallback aria-label={authorName ? t('chat.avatarOf', { name: authorName }) : t('chat.userAvatar')}>
|
||||
{authorName
|
||||
? authorName
|
||||
.split(' ')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/**
|
||||
* EN: TypingIndicator component props interface
|
||||
@@ -65,8 +66,11 @@ export function TypingIndicator({
|
||||
dotSize = 8,
|
||||
color,
|
||||
className,
|
||||
'aria-label': ariaLabel = 'AI is typing... / AI đang nhập...',
|
||||
'aria-label': ariaLabel,
|
||||
}: TypingIndicatorProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
const defaultAriaLabel = ariaLabel || t('chat.typing', { defaultValue: 'AI is typing...' });
|
||||
// EN: Generate array of dot indices for rendering / VI: Tạo mảng các chỉ số chấm để render
|
||||
const dots = React.useMemo(() => {
|
||||
return Array.from({ length: dotCount }, (_, i) => i);
|
||||
@@ -100,7 +104,7 @@ export function TypingIndicator({
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-label={defaultAriaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
{dots.map((index) => (
|
||||
|
||||
142
apps/web-client/src/contexts/i18n-context.tsx
Normal file
142
apps/web-client/src/contexts/i18n-context.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'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;
|
||||
}
|
||||
40
apps/web-client/src/hooks/use-translation.ts
Normal file
40
apps/web-client/src/hooks/use-translation.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* EN: Custom translation hook
|
||||
* VI: Hook translation tùy chỉnh
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useI18n } from '@/contexts/i18n-context';
|
||||
|
||||
/**
|
||||
* 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 t = useTranslations();
|
||||
const { locale, setLocale } = useI18n();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
30
apps/web-client/src/i18n/config.ts
Normal file
30
apps/web-client/src/i18n/config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
348
apps/web-client/src/i18n/messages/en.json
Normal file
348
apps/web-client/src/i18n/messages/en.json
Normal file
@@ -0,0 +1,348 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"register": {
|
||||
"title": "Sign Up",
|
||||
"createAccount": "Create Account",
|
||||
"description": "Create a new account to get started",
|
||||
"signUpToStart": "Sign up to get started",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"fullName": "Full Name",
|
||||
"createStrongPassword": "Create a strong password",
|
||||
"reEnterPassword": "Re-enter your password",
|
||||
"agreeToTerms": "I agree to the",
|
||||
"termsAndConditions": "Terms and Conditions",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"signIn": "Sign in",
|
||||
"signingUp": "Signing up...",
|
||||
"creatingAccount": "Creating account...",
|
||||
"registrationFailed": "Registration failed",
|
||||
"weak": "Weak",
|
||||
"fair": "Fair",
|
||||
"good": "Good",
|
||||
"strong": "Strong",
|
||||
"passwordStrength": "Password strength: {strength}"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Forgot Password",
|
||||
"description": "Enter your email address and we'll send you a link to reset your password",
|
||||
"checkEmail": "Check your email for reset instructions",
|
||||
"email": "Email",
|
||||
"sendResetLink": "Send Reset Link",
|
||||
"sending": "Sending...",
|
||||
"backToLogin": "Back to Login",
|
||||
"resetLinkSent": "Reset link sent!",
|
||||
"resetLinkSentDetail": "We've sent a password reset link to {email}",
|
||||
"checkInbox": "Please check your inbox and follow the instructions to reset your password. If you don't see the email, check your spam folder.",
|
||||
"sendToAnotherEmail": "Send to another email",
|
||||
"failedToSend": "Failed to send reset link",
|
||||
"noAccount": "Don't have an account?",
|
||||
"signUp": "Sign up"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "New Chat",
|
||||
"searchPlaceholder": "Search conversations...",
|
||||
"typeMessage": "Type your message...",
|
||||
"send": "Send message",
|
||||
"attachFile": "Attach file",
|
||||
"messageInput": "Message input",
|
||||
"sendHelp": "Press Enter to send, Shift+Enter for new line",
|
||||
"noConversations": "No conversations yet",
|
||||
"noConversationsFound": "No conversations found",
|
||||
"justNow": "Just now",
|
||||
"conversation": "Conversation",
|
||||
"conversationList": "Conversation list",
|
||||
"searchConversations": "Search conversations",
|
||||
"messages": "Chat messages",
|
||||
"startConversation": "Start a conversation",
|
||||
"startConversationDesc": "Type a message below to get started",
|
||||
"messageSent": "Message sent",
|
||||
"messageSendFailed": "Failed to send message",
|
||||
"newConversationCreated": "New conversation created",
|
||||
"switchedToConversation": "Switched to conversation",
|
||||
"messageCopied": "Message copied",
|
||||
"openSearch": "Open search",
|
||||
"loadingTypingIndicator": "Loading typing indicator",
|
||||
"typing": "AI is typing...",
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{minutes}m ago",
|
||||
"hoursAgo": "{hours}h ago",
|
||||
"daysAgo": "{days}d ago",
|
||||
"copyMessage": "Copy message",
|
||||
"copy": "Copy",
|
||||
"editMessage": "Edit message",
|
||||
"edit": "Edit",
|
||||
"deleteMessage": "Delete message",
|
||||
"delete": "Delete",
|
||||
"regenerateResponse": "Regenerate response",
|
||||
"regenerate": "Regenerate",
|
||||
"likeMessage": "Like message",
|
||||
"like": "Like",
|
||||
"dislikeMessage": "Dislike message",
|
||||
"dislike": "Dislike",
|
||||
"shareMessage": "Share message",
|
||||
"share": "Share",
|
||||
"aiAssistant": "AI assistant",
|
||||
"aiAssistantAvatar": "AI assistant avatar",
|
||||
"userAvatar": "User avatar",
|
||||
"avatarOf": "Avatar of {name}",
|
||||
"messageActions": "Message actions",
|
||||
"closeSidebar": "Close sidebar",
|
||||
"openSidebar": "Open sidebar",
|
||||
"conversationSidebar": "Conversation sidebar",
|
||||
"mainChatArea": "Main chat area",
|
||||
"conversationSettingsPanel": "Conversation settings panel"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"description": "Manage your account settings and preferences",
|
||||
"navigation": "Settings navigation",
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"label": "Profile",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
"phone": "Phone",
|
||||
"bio": "Bio",
|
||||
"email": "Email",
|
||||
"username": "Username",
|
||||
"verified": "Verified",
|
||||
"notVerified": "Not verified",
|
||||
"changeAvatar": "Change Avatar",
|
||||
"uploadAvatar": "Upload Avatar",
|
||||
"saveChanges": "Save Changes",
|
||||
"changesSaved": "Changes saved successfully",
|
||||
"failedToFetch": "Failed to fetch profile",
|
||||
"failedToUpdate": "Failed to update profile",
|
||||
"failedToUpload": "Failed to upload avatar",
|
||||
"uploadSuccess": "Avatar uploaded successfully",
|
||||
"selectImageFile": "Please select an image file",
|
||||
"imageSizeLimit": "Image size must be less than 5MB",
|
||||
"updateInfo": "Update your profile information and avatar",
|
||||
"enterFirstName": "Enter your first name",
|
||||
"enterLastName": "Enter your last name",
|
||||
"enterPhone": "Enter your phone number",
|
||||
"bioPlaceholder": "Tell us about yourself",
|
||||
"emailCannotChange": "Email cannot be changed",
|
||||
"usernameCannotChange": "Username cannot be changed",
|
||||
"notSet": "Not set"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"label": "Preferences",
|
||||
"description": "Customize your experience and application settings",
|
||||
"languageAndTheme": "Language & Theme",
|
||||
"languageAndThemeDesc": "Choose your preferred language and appearance",
|
||||
"language": "Language",
|
||||
"languageHelper": "Select your preferred language",
|
||||
"theme": "Theme",
|
||||
"themeHelper": "Choose your preferred theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"chatSettings": "Chat Settings",
|
||||
"chatSettingsDesc": "Customize your chat experience",
|
||||
"autoScroll": "Auto-scroll",
|
||||
"autoScrollDesc": "Automatically scroll to the latest message",
|
||||
"showTimestamps": "Show timestamps",
|
||||
"showTimestampsDesc": "Display message timestamps",
|
||||
"messageGrouping": "Message grouping",
|
||||
"messageGroupingHelper": "Choose how messages are grouped",
|
||||
"none": "None",
|
||||
"byAuthor": "By author",
|
||||
"byTime": "By time",
|
||||
"fontSize": "Font size",
|
||||
"fontSizeHelper": "Choose your preferred font size",
|
||||
"small": "Small",
|
||||
"medium": "Medium",
|
||||
"large": "Large",
|
||||
"extraLarge": "Extra Large",
|
||||
"accessibility": "Accessibility",
|
||||
"accessibilityDesc": "Improve accessibility and usability",
|
||||
"highContrast": "High contrast mode",
|
||||
"highContrastDesc": "Increase color contrast for better visibility",
|
||||
"screenReader": "Screen reader optimizations",
|
||||
"screenReaderDesc": "Enable additional ARIA labels and announcements",
|
||||
"savePreferences": "Save Preferences",
|
||||
"preferencesSaved": "Preferences saved successfully"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"label": "Security",
|
||||
"changePassword": "Change Password",
|
||||
"currentPassword": "Current Password",
|
||||
"currentPasswordRequired": "Current password is required",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
"twoFactorAuth": "Two-Factor Authentication",
|
||||
"enable2FA": "Enable 2FA",
|
||||
"disable2FA": "Disable 2FA",
|
||||
"confirmDisable2FA": "Are you sure you want to disable 2FA?",
|
||||
"2FAEnabled": "2FA enabled successfully",
|
||||
"2FADisabled": "2FA disabled successfully",
|
||||
"enable2FAFailed": "Failed to enable 2FA",
|
||||
"disable2FAFailed": "Failed to disable 2FA",
|
||||
"invalidVerificationCode": "Invalid verification code",
|
||||
"codeMustBe6Digits": "Code must be 6 digits",
|
||||
"activeSessions": "Active Sessions",
|
||||
"deviceName": "Device Name",
|
||||
"ipAddress": "IP Address",
|
||||
"lastActivity": "Last activity",
|
||||
"created": "Created",
|
||||
"revoke": "Revoke",
|
||||
"revokeSession": "Revoke Session",
|
||||
"never": "Never",
|
||||
"noActiveSessions": "No active sessions",
|
||||
"2FAStatus": "2FA Status",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"twoFactorDesc": "Add an extra layer of security to your account",
|
||||
"twoFactorInstructions": "Two-factor authentication adds an extra layer of security. When enabled, you'll need to enter a code from your authenticator app in addition to your password when signing in.",
|
||||
"manageSettings": "Manage your account security settings",
|
||||
"updatePasswordDesc": "Update your password to keep your account secure",
|
||||
"enterCurrentPassword": "Enter current password",
|
||||
"enterNewPassword": "Enter new password",
|
||||
"confirmNewPassword": "Confirm new password",
|
||||
"updatePassword": "Update Password",
|
||||
"manageDevices": "Manage devices that are signed in to your account",
|
||||
"unknownDevice": "Unknown Device",
|
||||
"confirmRevokeAll": "Are you sure you want to revoke all other sessions?",
|
||||
"revokeAllOtherSessions": "Revoke All Other Sessions",
|
||||
"setup2FA": "Set Up Two-Factor Authentication",
|
||||
"scanQRCode": "Scan this QR code with your authenticator app",
|
||||
"qrCodeAlt": "QR Code for two-factor authentication",
|
||||
"cantScan": "Can't scan? Enter this code manually",
|
||||
"verificationCode": "Verification Code",
|
||||
"enter6DigitCode": "Enter 6-digit code",
|
||||
"enterCodeFromApp": "Enter the 6-digit code from your authenticator app",
|
||||
"verifyAndEnable": "Verify & Enable"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"label": "Notifications"
|
||||
},
|
||||
"billing": {
|
||||
"title": "Billing",
|
||||
"label": "Billing"
|
||||
},
|
||||
"apiKeys": {
|
||||
"title": "API Keys",
|
||||
"label": "API Keys",
|
||||
"manageKeys": "Manage your API keys for programmatic access",
|
||||
"yourApiKeys": "Your API Keys",
|
||||
"createAndManage": "Create and manage API keys for accessing the API",
|
||||
"createApiKey": "Create API Key",
|
||||
"noApiKeys": "No API keys yet",
|
||||
"createFirstKey": "Create your first API key to get started",
|
||||
"name": "Name",
|
||||
"nameRequired": "Name is required",
|
||||
"description": "Description",
|
||||
"created": "Created",
|
||||
"lastUsed": "Last used",
|
||||
"expires": "Expires",
|
||||
"never": "Never",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"delete": "Delete",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"failedToLoad": "Failed to load API keys",
|
||||
"failedToCreate": "Failed to create API key",
|
||||
"failedToDelete": "Failed to delete API key",
|
||||
"deletedSuccessfully": "API key deleted successfully",
|
||||
"confirmDelete": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"newApiKeyCreated": "New API Key Created",
|
||||
"saveKeySecurely": "Make sure to copy your API key now. You won't be able to see it again!",
|
||||
"keyCopied": "API key copied to clipboard",
|
||||
"namePlaceholder": "e.g., Production Key, Development Key",
|
||||
"nameHelper": "A descriptive name for this API key",
|
||||
"descriptionPlaceholder": "Optional description for this API key",
|
||||
"newKeyFor": "Your new API key for \"{name}\"",
|
||||
"important": "Important",
|
||||
"securityBestPractices": "Security Best Practices",
|
||||
"practice1": "Keep your API keys secure and never share them publicly",
|
||||
"practice2": "Use environment variables or secure secret management tools",
|
||||
"practice3": "Rotate your API keys regularly",
|
||||
"practice4": "Delete unused API keys immediately",
|
||||
"practice5": "If a key is compromised, revoke it immediately and create a new one",
|
||||
"createForAccess": "Create a new API key for programmatic access",
|
||||
"copyApiKey": "Copy API Key",
|
||||
"iveCopied": "I've copied the key"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"fullNameRequired": "Full name is required",
|
||||
"fullNameMin": "Full name must be at least 2 characters",
|
||||
"fullNameMax": "Full name must be less than 100 characters",
|
||||
"passwordUppercase": "Password must contain at least one uppercase letter",
|
||||
"passwordLowercase": "Password must contain at least one lowercase letter",
|
||||
"passwordNumber": "Password must contain at least one number",
|
||||
"passwordSpecial": "Password must contain at least one special character",
|
||||
"termsRequired": "You must accept the terms and conditions"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"home": {
|
||||
"title": "GoodGo Platform",
|
||||
"welcome": "Welcome, {email}!",
|
||||
"role": "Role: {role}",
|
||||
"pleaseLogin": "Please log in to continue."
|
||||
}
|
||||
}
|
||||
348
apps/web-client/src/i18n/messages/vi.json
Normal file
348
apps/web-client/src/i18n/messages/vi.json
Normal file
@@ -0,0 +1,348 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"register": {
|
||||
"title": "Đăng ký",
|
||||
"createAccount": "Tạo tài khoản",
|
||||
"description": "Tạo tài khoản mới để bắt đầu",
|
||||
"signUpToStart": "Đăng ký để bắt đầu",
|
||||
"email": "Email",
|
||||
"password": "Mật khẩu",
|
||||
"confirmPassword": "Xác nhận mật khẩu",
|
||||
"fullName": "Họ tên",
|
||||
"createStrongPassword": "Tạo mật khẩu mạnh",
|
||||
"reEnterPassword": "Nhập lại mật khẩu",
|
||||
"agreeToTerms": "Tôi đồng ý với",
|
||||
"termsAndConditions": "Điều khoản và điều kiện",
|
||||
"alreadyHaveAccount": "Đã có tài khoản?",
|
||||
"signIn": "Đăng nhập",
|
||||
"signingUp": "Đang đăng ký...",
|
||||
"creatingAccount": "Đang tạo tài khoản...",
|
||||
"registrationFailed": "Đăng ký thất bại",
|
||||
"weak": "Yếu",
|
||||
"fair": "Trung bình",
|
||||
"good": "Tốt",
|
||||
"strong": "Mạnh",
|
||||
"passwordStrength": "Độ mạnh mật khẩu: {strength}"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Quên mật khẩu",
|
||||
"description": "Nhập địa chỉ email của bạn và chúng tôi sẽ gửi cho bạn liên kết để đặt lại mật khẩu",
|
||||
"checkEmail": "Kiểm tra email để xem hướng dẫn đặt lại",
|
||||
"email": "Email",
|
||||
"sendResetLink": "Gửi link đặt lại",
|
||||
"sending": "Đang gửi...",
|
||||
"backToLogin": "Quay lại đăng nhập",
|
||||
"resetLinkSent": "Link đặt lại đã được gửi!",
|
||||
"resetLinkSentDetail": "Chúng tôi đã gửi link đặt lại mật khẩu đến {email}",
|
||||
"checkInbox": "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.",
|
||||
"sendToAnotherEmail": "Gửi đến email khác",
|
||||
"failedToSend": "Gửi link đặt lại thất bại",
|
||||
"noAccount": "Chưa có tài khoản?",
|
||||
"signUp": "Đăng ký"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "Cuộc trò chuyện mới",
|
||||
"searchPlaceholder": "Tìm kiếm...",
|
||||
"typeMessage": "Nhập tin nhắn...",
|
||||
"send": "Gửi tin nhắn",
|
||||
"attachFile": "Đính kèm file",
|
||||
"messageInput": "Ô nhập tin nhắn",
|
||||
"sendHelp": "Nhấn Enter để gửi, Shift+Enter để xuống dòng",
|
||||
"noConversations": "Chưa có cuộc trò chuyện nào",
|
||||
"noConversationsFound": "Không tìm thấy cuộc trò chuyện",
|
||||
"justNow": "Vừa xong",
|
||||
"conversation": "Cuộc trò chuyện",
|
||||
"conversationList": "Danh sách cuộc trò chuyện",
|
||||
"searchConversations": "Tìm kiếm cuộc trò chuyện",
|
||||
"messages": "Tin nhắn chat",
|
||||
"startConversation": "Bắt đầu cuộc trò chuyện",
|
||||
"startConversationDesc": "Nhập tin nhắn bên dưới để bắt đầu",
|
||||
"messageSent": "Tin nhắn đã gửi",
|
||||
"messageSendFailed": "Không thể gửi tin nhắn",
|
||||
"newConversationCreated": "Đã tạo cuộc trò chuyện mới",
|
||||
"switchedToConversation": "Đã chuyển sang cuộc trò chuyện",
|
||||
"messageCopied": "Đã sao chép tin nhắn",
|
||||
"openSearch": "Mở tìm kiếm",
|
||||
"loadingTypingIndicator": "Đang tải chỉ báo đang gõ",
|
||||
"typing": "AI đang nhập...",
|
||||
"justNow": "Vừa xong",
|
||||
"minutesAgo": "{minutes} phút trước",
|
||||
"hoursAgo": "{hours} giờ trước",
|
||||
"daysAgo": "{days} ngày trước",
|
||||
"copyMessage": "Sao chép tin nhắn",
|
||||
"copy": "Sao chép",
|
||||
"editMessage": "Chỉnh sửa tin nhắn",
|
||||
"edit": "Chỉnh sửa",
|
||||
"deleteMessage": "Xóa tin nhắn",
|
||||
"delete": "Xóa",
|
||||
"regenerateResponse": "Tạo lại phản hồi",
|
||||
"regenerate": "Tạo lại",
|
||||
"likeMessage": "Thích tin nhắn",
|
||||
"like": "Thích",
|
||||
"dislikeMessage": "Không thích tin nhắn",
|
||||
"dislike": "Không thích",
|
||||
"shareMessage": "Chia sẻ tin nhắn",
|
||||
"share": "Chia sẻ",
|
||||
"aiAssistant": "Trợ lý AI",
|
||||
"aiAssistantAvatar": "Avatar trợ lý AI",
|
||||
"userAvatar": "Avatar người dùng",
|
||||
"avatarOf": "Avatar của {name}",
|
||||
"messageActions": "Các hành động cho message",
|
||||
"closeSidebar": "Đóng sidebar",
|
||||
"openSidebar": "Mở sidebar",
|
||||
"conversationSidebar": "Sidebar cuộc trò chuyện",
|
||||
"mainChatArea": "Khu vực chat chính",
|
||||
"conversationSettingsPanel": "Panel cài đặt cuộc trò chuyện"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Cài đặt",
|
||||
"description": "Quản lý cài đặt và tùy chọn tài khoản",
|
||||
"navigation": "Điều hướng Settings",
|
||||
"profile": {
|
||||
"title": "Hồ sơ",
|
||||
"label": "Hồ sơ",
|
||||
"firstName": "Tên",
|
||||
"lastName": "Họ",
|
||||
"phone": "Số điện thoại",
|
||||
"bio": "Tiểu sử",
|
||||
"email": "Email",
|
||||
"username": "Tên người dùng",
|
||||
"verified": "Đã xác thực",
|
||||
"notVerified": "Chưa xác thực",
|
||||
"changeAvatar": "Đổi Avatar",
|
||||
"uploadAvatar": "Tải lên Avatar",
|
||||
"saveChanges": "Lưu thay đổi",
|
||||
"changesSaved": "Đã lưu thay đổi thành công",
|
||||
"failedToFetch": "Không thể lấy profile",
|
||||
"failedToUpdate": "Không thể cập nhật profile",
|
||||
"failedToUpload": "Không thể upload avatar",
|
||||
"uploadSuccess": "Avatar đã được upload thành công",
|
||||
"selectImageFile": "Vui lòng chọn file ảnh",
|
||||
"imageSizeLimit": "Kích thước ảnh phải nhỏ hơn 5MB",
|
||||
"updateInfo": "Cập nhật thông tin hồ sơ và avatar",
|
||||
"enterFirstName": "Nhập tên của bạn",
|
||||
"enterLastName": "Nhập họ của bạn",
|
||||
"enterPhone": "Nhập số điện thoại của bạn",
|
||||
"bioPlaceholder": "Hãy cho chúng tôi biết về bạn",
|
||||
"emailCannotChange": "Email không thể thay đổi",
|
||||
"usernameCannotChange": "Tên người dùng không thể thay đổi",
|
||||
"notSet": "Chưa đặt"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Tùy chọn",
|
||||
"label": "Tùy chọn",
|
||||
"description": "Tùy chỉnh trải nghiệm và cài đặt ứng dụng",
|
||||
"languageAndTheme": "Ngôn ngữ & Giao diện",
|
||||
"languageAndThemeDesc": "Chọn ngôn ngữ và giao diện ưa thích",
|
||||
"language": "Ngôn ngữ",
|
||||
"languageHelper": "Chọn ngôn ngữ ưa thích",
|
||||
"theme": "Giao diện",
|
||||
"themeHelper": "Chọn giao diện ưa thích",
|
||||
"light": "Sáng",
|
||||
"dark": "Tối",
|
||||
"system": "Hệ thống",
|
||||
"chatSettings": "Cài đặt Chat",
|
||||
"chatSettingsDesc": "Tùy chỉnh trải nghiệm chat",
|
||||
"autoScroll": "Tự động cuộn",
|
||||
"autoScrollDesc": "Tự động cuộn đến tin nhắn mới nhất",
|
||||
"showTimestamps": "Hiển thị thời gian",
|
||||
"showTimestampsDesc": "Hiển thị thời gian của tin nhắn",
|
||||
"messageGrouping": "Nhóm tin nhắn",
|
||||
"messageGroupingHelper": "Chọn cách nhóm tin nhắn",
|
||||
"none": "Không",
|
||||
"byAuthor": "Theo tác giả",
|
||||
"byTime": "Theo thời gian",
|
||||
"fontSize": "Kích thước chữ",
|
||||
"fontSizeHelper": "Chọn kích thước chữ ưa thích",
|
||||
"small": "Nhỏ",
|
||||
"medium": "Trung bình",
|
||||
"large": "Lớn",
|
||||
"extraLarge": "Rất lớn",
|
||||
"accessibility": "Khả năng truy cập",
|
||||
"accessibilityDesc": "Cải thiện khả năng truy cập và sử dụng",
|
||||
"highContrast": "Chế độ tương phản cao",
|
||||
"highContrastDesc": "Tăng độ tương phản màu sắc để dễ nhìn hơn",
|
||||
"screenReader": "Tối ưu cho screen reader",
|
||||
"screenReaderDesc": "Bật thêm ARIA labels và thông báo",
|
||||
"savePreferences": "Lưu tùy chọn",
|
||||
"preferencesSaved": "Đã lưu preferences thành công"
|
||||
},
|
||||
"security": {
|
||||
"title": "Bảo mật",
|
||||
"label": "Bảo mật",
|
||||
"changePassword": "Đổi mật khẩu",
|
||||
"currentPassword": "Mật khẩu hiện tại",
|
||||
"currentPasswordRequired": "Mật khẩu hiện tại là bắt buộc",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"confirmPassword": "Xác nhận mật khẩu",
|
||||
"passwordChanged": "Mật khẩu đã được thay đổi thành công",
|
||||
"passwordChangeFailed": "Không thể thay đổi mật khẩu",
|
||||
"twoFactorAuth": "Xác thực hai yếu tố",
|
||||
"enable2FA": "Bật 2FA",
|
||||
"disable2FA": "Tắt 2FA",
|
||||
"confirmDisable2FA": "Bạn có chắc chắn muốn tắt 2FA?",
|
||||
"2FAEnabled": "2FA đã được bật thành công",
|
||||
"2FADisabled": "2FA đã được tắt thành công",
|
||||
"enable2FAFailed": "Không thể bật 2FA",
|
||||
"disable2FAFailed": "Không thể tắt 2FA",
|
||||
"invalidVerificationCode": "Mã xác thực không hợp lệ",
|
||||
"codeMustBe6Digits": "Mã phải có 6 chữ số",
|
||||
"activeSessions": "Phiên đăng nhập đang hoạt động",
|
||||
"deviceName": "Tên thiết bị",
|
||||
"ipAddress": "Địa chỉ IP",
|
||||
"lastActivity": "Hoạt động cuối",
|
||||
"created": "Đã tạo",
|
||||
"revoke": "Thu hồi",
|
||||
"revokeSession": "Thu hồi phiên",
|
||||
"never": "Không bao giờ",
|
||||
"noActiveSessions": "Không có phiên đăng nhập đang hoạt động",
|
||||
"2FAStatus": "Trạng thái 2FA",
|
||||
"enabled": "Đã bật",
|
||||
"disabled": "Đã tắt",
|
||||
"twoFactorDesc": "Thêm một lớp bảo mật bổ sung cho tài khoản",
|
||||
"twoFactorInstructions": "Xác thực hai yếu tố thêm một lớp bảo mật bổ sung. Khi được bật, bạn sẽ cần nhập mã từ ứng dụng xác thực của mình ngoài mật khẩu khi đăng nhập.",
|
||||
"manageSettings": "Quản lý cài đặt bảo mật tài khoản",
|
||||
"updatePasswordDesc": "Cập nhật mật khẩu để giữ tài khoản an toàn",
|
||||
"enterCurrentPassword": "Nhập mật khẩu hiện tại",
|
||||
"enterNewPassword": "Nhập mật khẩu mới",
|
||||
"confirmNewPassword": "Xác nhận mật khẩu mới",
|
||||
"updatePassword": "Cập nhật mật khẩu",
|
||||
"manageDevices": "Quản lý các thiết bị đã đăng nhập vào tài khoản",
|
||||
"unknownDevice": "Thiết bị không xác định",
|
||||
"confirmRevokeAll": "Bạn có chắc chắn muốn thu hồi tất cả các session khác?",
|
||||
"revokeAllOtherSessions": "Thu hồi tất cả sessions khác",
|
||||
"setup2FA": "Thiết lập Xác thực Hai yếu tố",
|
||||
"scanQRCode": "Quét mã QR này bằng ứng dụng xác thực của bạn",
|
||||
"qrCodeAlt": "Mã QR cho xác thực hai yếu tố",
|
||||
"cantScan": "Không thể quét? Nhập mã này theo cách thủ công",
|
||||
"verificationCode": "Mã xác thực",
|
||||
"enter6DigitCode": "Nhập mã 6 chữ số",
|
||||
"enterCodeFromApp": "Nhập mã 6 chữ số từ ứng dụng xác thực của bạn",
|
||||
"verifyAndEnable": "Xác thực & Bật"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Thông báo",
|
||||
"label": "Thông báo"
|
||||
},
|
||||
"billing": {
|
||||
"title": "Thanh toán",
|
||||
"label": "Thanh toán"
|
||||
},
|
||||
"apiKeys": {
|
||||
"title": "Khóa API",
|
||||
"label": "Khóa API",
|
||||
"manageKeys": "Quản lý khóa API để truy cập theo chương trình",
|
||||
"yourApiKeys": "Khóa API của bạn",
|
||||
"createAndManage": "Tạo và quản lý khóa API để truy cập API",
|
||||
"createApiKey": "Tạo khóa API",
|
||||
"noApiKeys": "Chưa có khóa API nào",
|
||||
"createFirstKey": "Tạo khóa API đầu tiên để bắt đầu",
|
||||
"name": "Tên",
|
||||
"nameRequired": "Tên là bắt buộc",
|
||||
"description": "Mô tả",
|
||||
"created": "Đã tạo",
|
||||
"lastUsed": "Sử dụng lần cuối",
|
||||
"expires": "Hết hạn",
|
||||
"never": "Không bao giờ",
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép",
|
||||
"delete": "Xóa",
|
||||
"show": "Hiện",
|
||||
"hide": "Ẩn",
|
||||
"failedToLoad": "Không thể tải API keys",
|
||||
"failedToCreate": "Không thể tạo API key",
|
||||
"failedToDelete": "Không thể xóa API key",
|
||||
"deletedSuccessfully": "API key đã được xóa thành công",
|
||||
"confirmDelete": "Bạn có chắc chắn muốn xóa \"{name}\"? Hành động này không thể hoàn tác.",
|
||||
"newApiKeyCreated": "Đã tạo khóa API mới",
|
||||
"saveKeySecurely": "Hãy chắc chắn sao chép khóa API của bạn ngay bây giờ. Bạn sẽ không thể xem lại nó!",
|
||||
"keyCopied": "Đã sao chép API key vào clipboard",
|
||||
"namePlaceholder": "VD: Production Key, Development Key",
|
||||
"nameHelper": "Tên mô tả cho khóa API này",
|
||||
"descriptionPlaceholder": "Mô tả tùy chọn cho khóa API này",
|
||||
"newKeyFor": "Khóa API mới cho \"{name}\"",
|
||||
"important": "Quan trọng",
|
||||
"securityBestPractices": "Thực hành bảo mật tốt nhất",
|
||||
"practice1": "Giữ khóa API của bạn an toàn và không bao giờ chia sẻ công khai",
|
||||
"practice2": "Sử dụng biến môi trường hoặc công cụ quản lý bí mật an toàn",
|
||||
"practice3": "Xoay khóa API thường xuyên",
|
||||
"practice4": "Xóa khóa API không sử dụng ngay lập tức",
|
||||
"practice5": "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",
|
||||
"createForAccess": "Tạo khóa API mới để truy cập theo chương trình",
|
||||
"copyApiKey": "Sao chép khóa API",
|
||||
"iveCopied": "Tôi đã sao chép khóa"
|
||||
}
|
||||
},
|
||||
"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ệ",
|
||||
"fullNameRequired": "Họ tên là bắt buộc",
|
||||
"fullNameMin": "Họ tên phải có ít nhất 2 ký tự",
|
||||
"fullNameMax": "Họ tên phải ít hơn 100 ký tự",
|
||||
"passwordUppercase": "Mật khẩu phải chứa ít nhất một chữ hoa",
|
||||
"passwordLowercase": "Mật khẩu phải chứa ít nhất một chữ thường",
|
||||
"passwordNumber": "Mật khẩu phải chứa ít nhất một số",
|
||||
"passwordSpecial": "Mật khẩu phải chứa ít nhất một ký tự đặc biệt",
|
||||
"termsRequired": "Bạn phải chấp nhận điều khoản và điều kiện"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"home": {
|
||||
"title": "Nền tảng GoodGo",
|
||||
"welcome": "Chào mừng, {email}!",
|
||||
"role": "Vai trò: {role}",
|
||||
"pleaseLogin": "Vui lòng đăng nhập để tiếp tục."
|
||||
}
|
||||
}
|
||||
29
apps/web-client/src/i18n/request.ts
Normal file
29
apps/web-client/src/i18n/request.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* EN: Request handler for next-intl with locale detection
|
||||
* VI: Request handler cho next-intl với phát hiện locale
|
||||
*/
|
||||
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { defaultLocale, isValidLocale } from './config';
|
||||
|
||||
/**
|
||||
* EN: Detect locale from various sources
|
||||
* VI: Phát hiện locale từ các nguồn khác nhau
|
||||
*/
|
||||
function detectLocale(): string {
|
||||
// EN: In client-side, we'll use context/localStorage
|
||||
// VI: Ở client-side, chúng ta sẽ sử dụng context/localStorage
|
||||
// This is mainly for SSR compatibility
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
// EN: For client-side approach, we'll detect locale in the provider
|
||||
// VI: Với cách tiếp cận client-side, chúng ta sẽ phát hiện locale trong provider
|
||||
const locale = detectLocale();
|
||||
|
||||
return {
|
||||
locale: isValidLocale(locale) ? locale : defaultLocale,
|
||||
messages: (await import(`./messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
47
apps/web-client/src/providers/i18n-provider.tsx
Normal file
47
apps/web-client/src/providers/i18n-provider.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
throw new Error(response.error?.message || 'Login failed / Đăng nhập thất bại');
|
||||
throw new Error(response.error?.message || 'Login failed');
|
||||
}
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
@@ -93,7 +93,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
throw new Error(response.error?.message || 'Registration failed / Đăng ký thất bại');
|
||||
throw new Error(response.error?.message || 'Registration failed');
|
||||
}
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
|
||||
Reference in New Issue
Block a user