diff --git a/package-lock.json b/package-lock.json index 58b516e..7320ab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,14 @@ "@tailwindcss/vite": "^4.2.1", "axios": "^1.13.5", "clsx": "^2.1.1", + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", "jwt-decode": "^4.0.0", "lucide-react": "^0.575.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^16.5.8", "react-router-dom": "^7.13.1", "recharts": "^3.8.0", "tailwind-merge": "^3.5.0", @@ -271,6 +274,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2990,9 +3002,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3221,6 +3233,55 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.8.18", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", + "integrity": "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3991,6 +4052,33 @@ "react-dom": ">=16" } }, + "node_modules/react-i18next": { + "version": "16.5.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.8.tgz", + "integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", @@ -4335,7 +4423,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4522,6 +4610,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 2aac6b6..024e409 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,14 @@ "@tailwindcss/vite": "^4.2.1", "axios": "^1.13.5", "clsx": "^2.1.1", + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", "jwt-decode": "^4.0.0", "lucide-react": "^0.575.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^16.5.8", "react-router-dom": "^7.13.1", "recharts": "^3.8.0", "tailwind-merge": "^3.5.0", diff --git a/src/api/axios.ts b/src/api/axios.ts index 759c339..2df774e 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,5 +1,6 @@ import axios from 'axios' import { useAuthStore } from '../store/useAuthStore' +import i18n from '../i18n' const baseURL = import.meta.env.VITE_API_URL ? `${import.meta.env.VITE_API_URL}/api/v1` @@ -18,6 +19,11 @@ apiClient.interceptors.request.use( if (user?.accessToken && config.headers) { config.headers.Authorization = `Bearer ${user.accessToken}` } + + if (config.headers) { + config.headers['Accept-Language'] = i18n.language || 'en' + } + return config }, (error) => Promise.reject(error) diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..d68cc0a --- /dev/null +++ b/src/components/LanguageSwitcher.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next' +import { Globe } from 'lucide-react' + +export default function LanguageSwitcher() { + const { i18n } = useTranslation() + + const toggleLanguage = () => { + const newLang = i18n.language === 'en' ? 'vi' : 'en' + i18n.changeLanguage(newLang) + } + + return ( + + ) +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..63a5017 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,26 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import LanguageDetector from 'i18next-browser-languagedetector' + +import enTranslations from './locales/en.json' +import viTranslations from './locales/vi.json' + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + en: { translation: enTranslations }, + vi: { translation: viTranslations }, + }, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + detection: { + order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'], + caches: ['localStorage', 'cookie'], + }, + }) + +export default i18n diff --git a/src/layouts/AuthLayout.tsx b/src/layouts/AuthLayout.tsx index 1244dcd..a75e0c3 100644 --- a/src/layouts/AuthLayout.tsx +++ b/src/layouts/AuthLayout.tsx @@ -1,9 +1,12 @@ import { Outlet, Navigate, Link } from 'react-router-dom' import { useAuthStore } from '../store/useAuthStore' import { Toaster } from 'react-hot-toast' +import { useTranslation } from 'react-i18next' +import LanguageSwitcher from '../components/LanguageSwitcher' export default function AuthLayout() { const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + const { t } = useTranslation() if (isAuthenticated) { return @@ -16,11 +19,14 @@ export default function AuthLayout() { LinkForge -

Manage your links in one place

+

{t('login.manage_links')}

+
+ +
diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/DashboardLayout.tsx index 34c38b6..598effd 100644 --- a/src/layouts/DashboardLayout.tsx +++ b/src/layouts/DashboardLayout.tsx @@ -3,9 +3,12 @@ import { useAuthStore } from '../store/useAuthStore' import { apiClient } from '../api/axios' import { Toaster } from 'react-hot-toast' import { LayoutDashboard, LogOut, ShieldCheck, Star } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import LanguageSwitcher from '../components/LanguageSwitcher' export default function DashboardLayout() { const { isAuthenticated, user, clearAuth } = useAuthStore() + const { t } = useTranslation() const navigate = useNavigate() const location = useLocation() @@ -42,7 +45,7 @@ export default function DashboardLayout() { className="flex flex-col md:flex-row items-center gap-1 md:gap-3 p-2 md:px-4 md:py-3 rounded-xl hover:bg-gray-100 text-gray-700 font-medium transition-colors" > - Dashboard + {t('dashboard.sidebar.dashboard')} {user?.role !== 'ADMIN' && ( @@ -51,7 +54,7 @@ export default function DashboardLayout() { className="flex flex-col md:flex-row items-center gap-1 md:gap-3 p-2 md:px-4 md:py-3 rounded-xl hover:bg-amber-50 text-amber-700 font-medium transition-colors" > - Upgrade VIP + {t('dashboard.sidebar.upgrade_vip')} )} @@ -61,7 +64,7 @@ export default function DashboardLayout() { className="flex flex-col md:flex-row items-center gap-1 md:gap-3 p-2 md:px-4 md:py-3 rounded-xl hover:bg-purple-50 text-purple-700 font-medium transition-colors" > - Admin Panel + {t('dashboard.sidebar.admin_panel')} )} @@ -72,7 +75,7 @@ export default function DashboardLayout() { className="hidden md:flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-red-50 text-red-600 font-medium transition-colors w-full text-left mt-auto" > - Logout + {t('dashboard.sidebar.logout')} @@ -85,8 +88,8 @@ export default function DashboardLayout() {
{!(location.pathname.includes('/vip-upgrade') || location.pathname.includes('/payment-success')) && ( <> -

Dashboard

-

Welcome back, {user?.name}

+

{t('dashboard.sidebar.dashboard')}

+

{t('dashboard.welcome_back', { name: user?.name })}

)}
@@ -94,10 +97,14 @@ export default function DashboardLayout() {
{user?.vip && ( - VIP Active + {t('dashboard.vip_active')} )} +
+ +
+ ) : ( <> - Analytics - Delete Link - Login + {t('nav.login')} - Get Started + {t('nav.register')} )} +
+
@@ -56,7 +59,7 @@ export default function RootLayout() {
- © {new Date().getFullYear()} LinkForge. All rights reserved. + © {new Date().getFullYear()} LinkForge. {t('common.all_rights_reserved', { defaultValue: 'All rights reserved.' })}
diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 0000000..7fde5e1 --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,251 @@ +{ + "common": { + "loading": "Loading...", + "error": "Error", + "success": "Success", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "save": "Save", + "edit": "Edit", + "back": "Back", + "home": "Home", + "view_analytics": "View Analytics" + }, + "nav": { + "dashboard": "Dashboard", + "login": "Login", + "register": "Register", + "logout": "Logout", + "vip": "VIP Upgrade", + "admin": "Admin" + }, + "home": { + "title": "Shorten Your Links", + "title_highlight": "Expand Your Reach", + "subtitle": "LinkForge helps you create, track, and manage your links effortlessly with power and security.", + "shorten_btn": "Shorten Now", + "processing": "Processing...", + "placeholder": "Paste your long URL here...", + "already_logged_in": "You are already logged in to LinkForge.", + "go_to_dashboard": "Go to Dashboard", + "success_msg": "Link shortened successfully!", + "error_default": "An error occurred. Please try again later.", + "copied": "Copied to clipboard!", + "result_title": "Your shortened link", + "delete_token_title": "Delete Token (Save this!)", + "guest_warning": "Since you created this link as a guest, you must save this token if you ever wish to", + "delete_link_text": "delete the link", + "original_label": "Original:", + "custom_alias": "Custom Alias (Optional)" + }, + "login": { + "title": "Welcome back", + "subtitle": "Please enter your details to sign in.", + "email": "Email", + "password": "Password", + "submit": "Sign in", + "signing_in": "Signing in...", + "forgot_password": "Forgot password?", + "no_account": "Don't have an account?", + "signup": "Sign up", + "success_msg": "Welcome back, {{name}}!", + "manage_links": "Manage your links in one place", + "error_verify": "Please verify your email first", + "error_invalid": "Invalid email or password. Please try again." + }, + "register": { + "title": "Create account", + "subtitle": "Join LinkForge today and start shortening.", + "username": "Username", + "email": "Email", + "password": "Password", + "submit": "Create account", + "creating": "Creating account...", + "has_account": "Already have an account?", + "login": "Sign in", + "success_msg": "Account created! Please check your email for verification.", + "error_password_match": "Passwords do not match" + }, + "dashboard": { + "create_title": "Create New Short Link", + "shorten_btn": "Shorten", + "vip_ad_bypass": "Want to completely bypass advertisement pages for your visitors?", + "upgrade_vip": "Upgrade to VIP today!", + "just_created": "Just Created", + "my_links": "My Links", + "total_links": "{{count}} link total", + "total_links_plural": "{{count}} links total", + "search_placeholder": "Search links...", + "no_links": "No links yet", + "create_first": "Create your first short link above!", + "status_expired": "Expired", + "status_never": "Never", + "qr_modal_title": "QR Code", + "qr_not_generated": "No QR code generated yet", + "qr_generate": "Generate QR Code", + "qr_delete": "Delete QR Code", + "qr_vip_only": "QR codes are exclusive to VIP members.", + "qr_scan_share": "Scan to share {{url}}", + "delete_qr_title": "Delete QR Code?", + "delete_qr_desc": "This will permanently remove the QR code for this link. You can always regenerate it later.", + "delete_link_title": "Delete this link?", + "delete_link_desc": "This action cannot be undone. All analytics data for this link will also be lost forever.", + "confirm_delete_qr": "Yes, Delete QR Code", + "confirm_delete_link": "Confirm Delete", + "welcome_back": "Welcome back, {{name}}", + "vip_active": "VIP Active", + "sidebar": { + "dashboard": "Dashboard", + "upgrade_vip": "Upgrade VIP", + "admin_panel": "Admin Panel", + "logout": "Logout" + } + }, + "table": { + "created": "Created", + "expires": "Expires", + "url": "URL", + "clicks": "Clicks", + "actions": "Actions" + }, + "pagination": { + "page_info": "Page {{current}} of {{total}}", + "show": "Show", + "first": "First Page", + "prev": "Previous Page", + "next": "Next Page", + "last": "Last Page" + }, + "verify_email": { + "title": "Verify your email", + "subtitle": "We sent a 6-digit code to {{email}}", + "code_label": "Verification Code", + "submit": "Verify Email", + "verifying": "Verifying...", + "resend_btn": "Resend code", + "resend_cooldown": "Resend code in {{count}}s", + "resend_success": "New verification code sent!", + "success_title": "Email Verified!", + "success_subtitle": "Redirecting to sign in...", + "error_default": "Verification failed" + }, + "forgot_password": { + "title": "Forgot password?", + "reset_title": "Reset password", + "subtitle": "Enter your email and we'll send you a reset code.", + "reset_subtitle": "Enter the code sent to {{email}}", + "email_label": "Email", + "code_label": "Reset Code", + "new_password_label": "New Password", + "confirm_password_label": "Confirm New Password", + "submit_send": "Send Reset Code", + "submit_reset": "Reset Password", + "sending": "Sending...", + "resetting": "Resetting...", + "back_to_login": "Back to log in", + "remember_password": "Remember your password?", + "login": "Sign in", + "success_send": "Reset code sent to your email!", + "success_reset": "Password reset successfully!", + "success_title": "Password Reset!", + "success_subtitle": "Redirecting to sign in...", + "error_send": "Failed to send reset code", + "error_reset": "Reset failed", + "error_invalid_otp": "Invalid or expired OTP." + }, + "not_found": { + "title": "404 - Page Not Found", + "subtitle": "Sorry, the page you're looking for doesn't exist.", + "back_home": "Go back home" + }, + "vip": { + "title": "Upgrade Your Experience", + "subtitle": "Get exclusive features, bypass ads on all your short links, and supercharge your LinkForge account.", + "status": { + "active": "VIP Active", + "expires": "Expires: {{date}}", + "remaining": "({{count}} days remaining)", + "permanent": "Permanent — No expiration", + "permanent_desc": "You have lifetime access to all premium features. No renewal needed.", + "permanent_title": "Permanent VIP!", + "active_plan": "Active Plan", + "extend_title": "Extend your VIP", + "upgrade_ready": "Ready to upgrade?" + }, + "benefits": { + "title": "VIP Benefits", + "feature_ads": "Bypass all ad interstitials automatically", + "feature_links": "Unlimited short links creation", + "feature_support": "Priority email support 24/7", + "feature_analytics": "Advanced link analytics (Coming soon)" + }, + "packages": { + "1_month": "1 Month", + "3_months": "3 Months", + "1_year": "1 Year", + "duration": "{{count}} days", + "save": "Save {{percent}}%", + "per_month": "~{{price}} VND / month", + "popular": "Popular", + "selected": "Selected", + "select_plan": "Select this plan" + }, + "checkout": { + "vnpay_desc": "Secure payment powered by VNPay.", + "you_pay": "You pay", + "currency": "VND", + "upgrade_btn": "Upgrade with VNPay", + "extend_btn": "Extend with VNPay", + "processing": "Processing...", + "error_init": "Failed to initialize payment.", + "error_default": "An error occurred while creating payment link." + }, + "info": "Your subscription helps keep LinkForge running and allows us to develop new features. Payments are securely processed through the VNPay gateway. Subscriptions can be canceled at any time." + }, + "admin": { + "title": "Manage Users", + "subtitle": "View and manage system users and VIP access", + "search_placeholder": "Search by name or email...", + "loading_users": "Loading users...", + "no_users": "No users found.", + "table": { + "id": "ID", + "username": "Username", + "email": "Email", + "role": "Role", + "vip_access": "VIP Access", + "expiration": "Expiration" + }, + "pagination": { + "showing": "Showing page {{current}} of {{total}}" + }, + "errors": { + "fetch_failed": "Failed to fetch users", + "fetch_error": "An error occurred while fetching users", + "toggle_vip_failed": "Failed to toggle VIP status.", + "toggle_vip_error": "An error occurred while toggling VIP status." + }, + "user_links": { + "back": "Back to Admin Dashboard", + "title": "Manage User Links", + "subtitle": "Viewing links for User ID: {{userId}}", + "search_placeholder": "Search links...", + "no_links": "No links found", + "no_links_desc": "This user hasn't created any links matching your criteria.", + "short_link_copied": "Copied to clipboard!", + "delete_link_title": "Delete Link?", + "delete_link_desc": "Are you sure you want to permanently delete the link \"{{code}}\"? This action cannot be undone.", + "delete_success": "Link deleted successfully!", + "delete_error": "Failed to delete link.", + "qr_generated": "QR Code generated!", + "qr_generate_error": "Failed to generate QR code.", + "qr_deleted": "QR Code deleted!", + "qr_delete_error": "Failed to delete QR code.", + "view_qr": "View QR Code", + "generate_qr": "Generate QR Code", + "delete_qr": "Delete QR Code", + "scan_to_share": "Scan to share {{url}}" + } + } +} diff --git a/src/locales/vi.json b/src/locales/vi.json new file mode 100644 index 0000000..2398138 --- /dev/null +++ b/src/locales/vi.json @@ -0,0 +1,251 @@ +{ + "common": { + "loading": "Đang tải...", + "error": "Lỗi", + "success": "Thành công", + "cancel": "Hủy", + "confirm": "Xác nhận", + "delete": "Xóa", + "save": "Lưu", + "edit": "Sửa", + "back": "Quay lại", + "home": "Trang chủ", + "view_analytics": "Xem phân tích" + }, + "nav": { + "dashboard": "Bảng điều khiển", + "login": "Đăng nhập", + "register": "Đăng ký", + "logout": "Đăng xuất", + "vip": "Nâng cấp VIP", + "admin": "Quản trị" + }, + "home": { + "title": "Rút gọn liên kết", + "title_highlight": "Mở rộng tầm với", + "subtitle": "LinkForge giúp bạn tạo, theo dõi và quản lý liên kết một cách dễ dàng, mạnh mẽ và an toàn.", + "shorten_btn": "Rút gọn ngay", + "processing": "Đang xử lý...", + "placeholder": "Dán URL dài của bạn tại đây...", + "already_logged_in": "Bạn đã đăng nhập vào LinkForge.", + "go_to_dashboard": "Đi đến Bảng điều khiển", + "success_msg": "Rút gọn liên kết thành công!", + "error_default": "Đã xảy ra lỗi. Vui lòng thử lại sau.", + "copied": "Đã sao chép vào bộ nhớ tạm!", + "result_title": "Liên kết đã rút gọn của bạn", + "delete_token_title": "Mã xóa (Hãy lưu lại!)", + "guest_warning": "Vì bạn tạo liên kết này với tư cách khách, bạn phải lưu mã này nếu muốn", + "delete_link_text": "xóa liên kết", + "original_label": "Gốc:", + "custom_alias": "Bí danh tùy chỉnh" + }, + "login": { + "title": "Chào mừng trở lại", + "subtitle": "Vui lòng nhập thông tin để đăng nhập.", + "email": "Email", + "password": "Mật khẩu", + "submit": "Đăng nhập", + "signing_in": "Đang đăng nhập...", + "forgot_password": "Quên mật khẩu?", + "no_account": "Chưa có tài khoản?", + "signup": "Đăng ký ngay", + "success_msg": "Chào mừng trở lại, {{name}}!", + "manage_links": "Quản lý liên kết của bạn tại một nơi", + "error_verify": "Vui lòng xác thực email trước", + "error_invalid": "Email hoặc mật khẩu không đúng. Vui lòng thử lại." + }, + "register": { + "title": "Tạo tài khoản", + "subtitle": "Tham gia LinkForge ngay hôm nay.", + "username": "Tên người dùng", + "email": "Email", + "password": "Mật khẩu", + "submit": "Tạo tài khoản", + "creating": "Đang tạo tài khoản...", + "has_account": "Đã có tài khoản?", + "login": "Đăng nhập", + "success_msg": "Đã tạo tài khoản! Vui lòng kiểm tra email để xác thực.", + "error_password_match": "Mật khẩu không khớp" + }, + "dashboard": { + "create_title": "Tạo liên kết rút gọn mới", + "shorten_btn": "Rút gọn", + "vip_ad_bypass": "Muốn bỏ qua hoàn toàn các trang quảng cáo cho khách truy cập của bạn?", + "upgrade_vip": "Nâng cấp lên VIP ngay hôm nay!", + "just_created": "Vừa tạo xong", + "my_links": "Liên kết của tôi", + "total_links": "Tổng cộng {{count}} liên kết", + "total_links_plural": "Tổng cộng {{count}} liên kết", + "search_placeholder": "Tìm kiếm liên kết...", + "no_links": "Chưa có liên kết nào", + "create_first": "Hãy tạo liên kết rút gọn đầu tiên của bạn ở trên!", + "status_expired": "Đã hết hạn", + "status_never": "Không bao giờ", + "qr_modal_title": "Mã QR", + "qr_not_generated": "Chưa có mã QR nào được tạo", + "qr_generate": "Tạo mã QR", + "qr_delete": "Xóa mã QR", + "qr_vip_only": "Mã QR chỉ dành riêng cho thành viên VIP.", + "qr_scan_share": "Quét để chia sẻ {{url}}", + "delete_qr_title": "Xóa mã QR?", + "delete_qr_desc": "Hành động này sẽ xóa vĩnh viễn mã QR cho liên kết này. Bạn luôn có thể tạo lại sau.", + "delete_link_title": "Xóa liên kết này?", + "delete_link_desc": "Hành động này không thể hoàn tác. Tất cả dữ liệu phân tích cho liên kết này cũng sẽ bị mất vĩnh viễn.", + "confirm_delete_qr": "Vâng, Xóa mã QR", + "confirm_delete_link": "Xác nhận xóa", + "welcome_back": "Chào mừng trở lại, {{name}}", + "vip_active": "VIP đang hoạt động", + "sidebar": { + "dashboard": "Bảng điều khiển", + "upgrade_vip": "Nâng cấp VIP", + "admin_panel": "Trang quản trị", + "logout": "Đăng xuất" + } + }, + "table": { + "created": "Đã tạo", + "expires": "Hết hạn", + "url": "URL", + "clicks": "Lượt nhấp", + "actions": "Hành động" + }, + "pagination": { + "page_info": "Trang {{current}} trên {{total}}", + "show": "Hiển thị", + "first": "Trang đầu", + "prev": "Trang trước", + "next": "Trang sau", + "last": "Trang cuối" + }, + "verify_email": { + "title": "Xác thực email", + "subtitle": "Chúng tôi đã gửi mã gồm 6 chữ số đến {{email}}", + "code_label": "Mã xác thực", + "submit": "Xác thực Email", + "verifying": "Đang xác thực...", + "resend_btn": "Gửi lại mã", + "resend_cooldown": "Gửi lại mã sau {{count}} giây", + "resend_success": "Mã xác thực mới đã được gửi!", + "success_title": "Email đã được xác thực!", + "success_subtitle": "Đang chuyển hướng đến trang đăng nhập...", + "error_default": "Xác thực thất bại" + }, + "forgot_password": { + "title": "Quên mật khẩu?", + "reset_title": "Đặt lại mật khẩu", + "subtitle": "Nhập email của bạn và chúng tôi sẽ gửi mã đặt lại.", + "reset_subtitle": "Nhập mã đã được gửi đến {{email}}", + "email_label": "Email", + "code_label": "Mã đặt lại", + "new_password_label": "Mật khẩu mới", + "confirm_password_label": "Xác nhận mật khẩu mới", + "submit_send": "Gửi mã đặt lại", + "submit_reset": "Đặt lại mật khẩu", + "sending": "Đang gửi...", + "resetting": "Đang đặt lại...", + "back_to_login": "Quay lại đăng nhập", + "remember_password": "Đã nhớ mật khẩu?", + "login": "Đăng nhập", + "success_send": "Mã đặt lại đã được gửi đến email của bạn!", + "success_reset": "Đặt lại mật khẩu thành công!", + "success_title": "Đã đặt lại mật khẩu!", + "success_subtitle": "Đang chuyển hướng đến trang đăng nhập...", + "error_send": "Gửi mã đặt lại thất bại", + "error_reset": "Đặt lại thất bại", + "error_invalid_otp": "Mã OTP không hợp lệ hoặc đã hết hạn." + }, + "not_found": { + "title": "404 - Không tìm thấy trang", + "subtitle": "Rất tiếc, trang bạn đang tìm kiếm không tồn tại.", + "back_home": "Quay lại trang chủ" + }, + "vip": { + "title": "Nâng cấp trải nghiệm của bạn", + "subtitle": "Nhận các tính năng độc quyền, bỏ qua quảng cáo trên tất cả các liên kết rút gọn của bạn và tăng sức mạnh cho tài khoản LinkForge của bạn.", + "status": { + "active": "VIP đang hoạt động", + "expires": "Hết hạn: {{date}}", + "remaining": "(còn lại {{count}} ngày)", + "permanent": "Vĩnh viễn — Không hết hạn", + "permanent_desc": "Bạn có quyền truy cập trọn đời vào tất cả các tính năng cao cấp. Không cần gia hạn.", + "permanent_title": "VIP vĩnh viễn!", + "active_plan": "Gói đang hoạt động", + "extend_title": "Gia hạn VIP của bạn", + "upgrade_ready": "Sẵn sàng nâng cấp?" + }, + "benefits": { + "title": "Quyền lợi VIP", + "feature_ads": "Tự động bỏ qua tất cả các trang quảng cáo", + "feature_links": "Tạo liên kết rút gọn không giới hạn", + "feature_support": "Hỗ trợ email ưu tiên 24/7", + "feature_analytics": "Phân tích liên kết nâng cao (Sắp ra mắt)" + }, + "packages": { + "1_month": "1 Tháng", + "3_months": "3 Tháng", + "1_year": "1 Năm", + "duration": "{{count}} ngày", + "save": "Tiết kiệm {{percent}}%", + "per_month": "~{{price}} VND / tháng", + "popular": "Phổ biến", + "selected": "Đã chọn", + "select_plan": "Chọn gói này" + }, + "checkout": { + "vnpay_desc": "Thanh toán an toàn qua cổng VNPay.", + "you_pay": "Bạn thanh toán", + "currency": "VND", + "upgrade_btn": "Nâng cấp qua VNPay", + "extend_btn": "Gia hạn qua VNPay", + "processing": "Đang xử lý...", + "error_init": "Không thể khởi tạo thanh toán.", + "error_default": "Đã xảy ra lỗi khi tạo liên kết thanh toán." + }, + "info": "Gói đăng ký của bạn giúp duy trì hoạt động của LinkForge và cho phép chúng tôi phát triển các tính năng mới. Thanh toán được xử lý an toàn qua cổng VNPay. Có thể hủy đăng ký bất kỳ lúc nào." + }, + "admin": { + "title": "Quản lý người dùng", + "subtitle": "Xem và quản lý người dùng hệ thống và quyền truy cập VIP", + "search_placeholder": "Tìm kiếm theo tên hoặc email...", + "loading_users": "Đang tải người dùng...", + "no_users": "Không tìm thấy người dùng nào.", + "table": { + "id": "ID", + "username": "Tên đăng nhập", + "email": "Email", + "role": "Vai trò", + "vip_access": "Quyền VIP", + "expiration": "Hết hạn" + }, + "pagination": { + "showing": "Đang hiển thị trang {{current}} trên {{total}}" + }, + "errors": { + "fetch_failed": "Tải danh sách người dùng thất bại", + "fetch_error": "Đã xảy ra lỗi khi tải danh sách người dùng", + "toggle_vip_failed": "Thay đổi trạng thái VIP thất bại.", + "toggle_vip_error": "Đã xảy ra lỗi khi thay đổi trạng thái VIP." + }, + "user_links": { + "back": "Quay lại Bảng quản trị", + "title": "Quản lý liên kết người dùng", + "subtitle": "Đang xem liên kết của Người dùng ID: {{userId}}", + "search_placeholder": "Tìm kiếm liên kết...", + "no_links": "Không tìm thấy liên kết", + "no_links_desc": "Người dùng này chưa tạo bất kỳ liên kết nào khớp với tiêu chí của bạn.", + "short_link_copied": "Đã sao chép vào bộ nhớ tạm!", + "delete_link_title": "Xóa liên kết?", + "delete_link_desc": "Bạn có chắc chắn muốn xóa vĩnh viễn liên kết \"{{code}}\"? Hành động này không thể hoàn tác.", + "delete_success": "Xóa liên kết thành công!", + "delete_error": "Xóa liên kết thất bại.", + "qr_generated": "Đã tạo mã QR!", + "qr_generate_error": "Tạo mã QR thất bại.", + "qr_deleted": "Đã xóa mã QR!", + "qr_delete_error": "Xóa mã QR thất bại.", + "view_qr": "Xem mã QR", + "generate_qr": "Tạo mã QR", + "delete_qr": "Xóa mã QR", + "scan_to_share": "Quét để chia sẻ {{url}}" + } + } +} diff --git a/src/main.tsx b/src/main.tsx index bef5202..cd81cee 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' +import './i18n' import App from './App.tsx' createRoot(document.getElementById('root')!).render( diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index d2b9693..199ffb7 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -4,8 +4,10 @@ import type { ApiResponse, PageResponse, AdminUserResponse } from '../types' import { useAuthStore } from '../store/useAuthStore' import { Search, ChevronLeft, ChevronRight, Loader2, AlertCircle, LayoutDashboard } from 'lucide-react' import { Navigate, useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' export default function AdminDashboard() { + const { t, i18n } = useTranslation() const { user } = useAuthStore() const navigate = useNavigate() const [users, setUsers] = useState([]) @@ -33,14 +35,14 @@ export default function AdminDashboard() { setUsers(data.data.content) setTotalPages(data.data.totalPages) } else { - setError(data.message || 'Failed to fetch users') + setError(data.message || t('admin.errors.fetch_failed')) } } catch (err: any) { - setError(err.response?.data?.message || 'An error occurred while fetching users') + setError(err.response?.data?.message || t('admin.errors.fetch_error')) } finally { setIsLoading(false) } - }, [search, page]) + }, [search, page, t]) useEffect(() => { if (user && user.role === 'ADMIN') { @@ -80,10 +82,10 @@ export default function AdminDashboard() { : u )) } else { - setError(data.message || 'Failed to toggle VIP status.') + setError(data.message || t('admin.errors.toggle_vip_failed')) } } catch (err: any) { - setError(err.response?.data?.message || 'An error occurred while toggling VIP status.') + setError(err.response?.data?.message || t('admin.errors.toggle_vip_error')) } finally { setTogglingVipFor(null) } @@ -95,7 +97,7 @@ export default function AdminDashboard() { const formatDate = (dateString?: string) => { if (!dateString) return '-' - return new Date(dateString).toLocaleDateString() + return new Date(dateString).toLocaleDateString(i18n.language === 'en' ? 'en-US' : 'vi-VN') } return ( @@ -111,9 +113,9 @@ export default function AdminDashboard() {

- Manage Users + {t('admin.title')}

-

View and manage system users and VIP access

+

{t('admin.subtitle')}

@@ -124,7 +126,7 @@ export default function AdminDashboard() { type="text" value={search} onChange={(e) => setSearch(e.target.value)} - placeholder="Search by name or email..." + placeholder={t('admin.search_placeholder')} className="w-full bg-gray-50 text-gray-900 placeholder:text-gray-400 rounded-xl pl-10 pr-4 py-3 border border-gray-200 outline-none focus:bg-white focus:border-primary-500 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all font-medium text-sm" />
@@ -145,12 +147,12 @@ export default function AdminDashboard() { - - - - - - + + + + + + @@ -159,14 +161,14 @@ export default function AdminDashboard() { ) : users.length === 0 ? ( ) : ( @@ -222,7 +224,7 @@ export default function AdminDashboard() { {!isLoading && totalPages > 1 && (
- Showing page {page + 1} of {totalPages} + {t('admin.pagination.showing', { current: page + 1, total: totalPages })}
@@ -203,9 +205,9 @@ export default function AdminUserLinks() {

- Manage User Links + {t('admin.user_links.title')}

-

Viewing links for User ID: {userId}

+

{t('admin.user_links.subtitle', { userId })}

@@ -215,7 +217,7 @@ export default function AdminUserLinks() {
setKeyword(e.target.value)} className="w-full pl-10 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:bg-white transition-all" @@ -224,10 +226,10 @@ export default function AdminUserLinks() {
- - - - + + + +
@@ -239,8 +241,8 @@ export default function AdminUserLinks() { ) : links.length === 0 ? (
-

No links found

-

This user hasn't created any links matching your criteria.

+

{t('admin.user_links.no_links')}

+

{t('admin.user_links.no_links_desc')}

) : ( <> @@ -258,12 +260,12 @@ export default function AdminUserLinks() { {link.expired && ( - Expired + {t('dashboard.status_expired')} )}
-
+
{link.clickCount}
-
+
{formatDate(link.createdAt)}
-
+
- {link.expiresAt ? formatDate(link.expiresAt) : 'Never'} + {link.expiresAt ? formatDate(link.expiresAt) : t('dashboard.status_never')}
@@ -313,9 +315,9 @@ export default function AdminUserLinks() { {totalPages > 0 && (
- Page {page + 1} of {totalPages} + {t('pagination.page_info', { current: page + 1, total: totalPages })}
- Show: + {t('pagination.show')}: setKeyword(e.target.value)} className="w-full pl-10 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:bg-white transition-all" @@ -449,10 +451,10 @@ export default function Dashboard() { {/* Sort Controls */}
- - - - + + + +
@@ -464,8 +466,8 @@ export default function Dashboard() { ) : links.length === 0 ? (
-

No links yet

-

Create your first short link above!

+

{t('dashboard.no_links')}

+

{t('dashboard.create_first')}

) : ( <> @@ -483,12 +485,12 @@ export default function Dashboard() { {link.expired && ( - Expired + {t('dashboard.status_expired')} )}
-
+
{link.clickCount}
-
+
{formatDate(link.createdAt)}
-
+
- {link.expiresAt ? formatDate(link.expiresAt) : 'Never'} + {link.expiresAt ? formatDate(link.expiresAt) : t('dashboard.status_never')}
@@ -533,7 +535,7 @@ export default function Dashboard() { onClick={() => triggerDeleteLinkConfirm(link.shortCode)} disabled={deletingCode === link.shortCode} className="p-2 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50" - title="Delete link" + title={t('common.delete_link', { defaultValue: 'Delete link' })} > {deletingCode === link.shortCode ? @@ -550,9 +552,9 @@ export default function Dashboard() { {totalPages > 0 && (
- Page {page + 1} of {totalPages} + {t('pagination.page_info', { current: page + 1, total: totalPages })}
- Show: + {t('pagination.show')}:
- +
@@ -202,7 +204,7 @@ export default function ForgotPassword() {
- +
@@ -227,10 +229,10 @@ export default function ForgotPassword() { {isLoading ? ( <> - Resetting... + {t('forgot_password.resetting')} ) : ( - 'Reset Password' + t('forgot_password.submit_reset') )} @@ -242,16 +244,16 @@ export default function ForgotPassword() { className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-500 disabled:text-gray-400 disabled:cursor-not-allowed transition-colors" > - {cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'} + {cooldown > 0 ? t('verify_email.resend_cooldown', { count: cooldown }) : t('verify_email.resend_btn')}
)}

- Remember your password?{' '} + {t('forgot_password.remember_password')}{' '} - Sign in + {t('forgot_password.login')}

diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index b8c3d2c..8ca6980 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,12 +1,14 @@ import { useState } from 'react' import { Link } from 'react-router-dom' import { toast } from 'react-hot-toast' +import { useTranslation } from 'react-i18next' import { apiClient } from '../api/axios' import type { ShortLinkResponse, ApiResponse } from '../types' import { Copy, Check, Link as LinkIcon, AlertCircle, LayoutDashboard, BarChart3 } from 'lucide-react' import { useAuthStore } from '../store/useAuthStore' export default function Home() { + const { t } = useTranslation() const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const [url, setUrl] = useState('') @@ -33,14 +35,15 @@ export default function Home() { if (data.success) { setResult(data.data) setUrl('') - toast.success('Link shortened successfully!') + toast.success(t('home.success_msg')) } else { - setError(data.message || 'Failed to create short link.') - toast.error(data.message || 'Failed to create short link.') + setError(data.message || t('home.error_default')) + toast.error(data.message || t('home.error_default')) } } catch (err: any) { - setError(err.response?.data?.message || 'An error occurred. Please try again later.') - toast.error(err.response?.data?.message || 'An error occurred.') + const errMsg = err.response?.data?.message || t('home.error_default') + setError(errMsg) + toast.error(errMsg) } finally { setIsLoading(false) } @@ -48,7 +51,7 @@ export default function Home() { const copyToClipboard = (text: string, type: 'url' | 'token') => { navigator.clipboard.writeText(text) - toast.success('Copied to clipboard!') + toast.success(t('home.copied')) if (type === 'url') { setCopiedUrl(true) setTimeout(() => setCopiedUrl(false), 2000) @@ -63,13 +66,13 @@ export default function Home() {

- Shorten Your Links
+ {t('home.title')}
- Expand Your Reach + {t('home.title_highlight')}

- LinkForge helps you create, track, and manage your links effortlessly with power and security. + {t('home.subtitle')}

@@ -80,9 +83,9 @@ export default function Home() { className="bg-primary-600 hover:bg-primary-700 text-white px-10 py-5 rounded-full font-bold text-xl transition-all shadow-xl hover:shadow-indigo-500/20 flex items-center justify-center gap-3 w-full md:w-auto" > - Go to Dashboard + {t('home.go_to_dashboard')} -

You are already logged in to LinkForge.

+

{t('home.already_logged_in')}

) : ( <> @@ -93,8 +96,8 @@ export default function Home() { type="url" value={url} onChange={(e) => setUrl(e.target.value)} - placeholder="Paste your long URL here..." - className="w-full bg-transparent outline-none text-gray-900 placeholder:text-gray-400 font-medium text-base" + placeholder={t('home.placeholder')} + className="w-full bg-transparent outline-none text-gray-900 placeholder:text-gray-400 font-medium text-sm md:text-base" required disabled={isLoading} /> @@ -103,7 +106,7 @@ export default function Home() { @@ -128,7 +131,7 @@ export default function Home() {
-

Your shortened link

+

{t('home.result_title')}

{window.location.origin}/r/{result.shortCode} @@ -137,14 +140,14 @@ export default function Home() { @@ -155,7 +158,7 @@ export default function Home() { {result.deleteToken && (
-

Delete Token (Save this!)

+

{t('home.delete_token_title')}

@@ -164,14 +167,16 @@ export default function Home() {

- Since you created this link as a guest, you must save this token if you ever wish to{' '} - delete the link. + {t('home.guest_warning')}{' '} + + {t('home.delete_link_text')} + .

)} @@ -179,7 +184,7 @@ export default function Home() {

- Original: + {t('home.original_label')} {result.originalUrl} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index d963c83..4a1f2c8 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,12 +1,14 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { toast } from 'react-hot-toast' +import { useTranslation } from 'react-i18next' import { apiClient } from '../api/axios' import type { ApiResponse, AuthResponse } from '../types' import { useAuthStore } from '../store/useAuthStore' import { Mail, Lock, AlertCircle, Loader2 } from 'lucide-react' export default function Login() { + const { t } = useTranslation() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -27,21 +29,21 @@ export default function Login() { if (data.success && data.data) { setAuth(data.data) - toast.success(`Welcome back, ${data.data.email}!`) + toast.success(t('login.success_msg', { name: data.data.email })) navigate('/dashboard') } else { - setError(data.message || 'Login failed') - toast.error(data.message || 'Login failed') + setError(data.message || t('login.error_invalid')) + toast.error(data.message || t('login.error_invalid')) } } catch (err: any) { - const message = err.response?.data?.message || 'Invalid email or password. Please try again.' - if (message.toLowerCase().includes('not verified')) { - toast.error('Please verify your email first') + const message = err.response?.data?.message || t('login.error_invalid') + if (message.toLowerCase().includes('not verified') || message.toLowerCase().includes('xác thực')) { + toast.error(t('login.error_verify')) navigate(`/verify-email?email=${encodeURIComponent(email)}`) return } setError(message) - toast.error('Login failed') + toast.error(t('login.error_invalid')) } finally { setIsLoading(false) } @@ -50,8 +52,8 @@ export default function Login() { return (

-

Welcome back

-

Please enter your details to sign in.

+

{t('login.title')}

+

{t('login.subtitle')}

{error && ( @@ -63,7 +65,7 @@ export default function Login() {
- +
@@ -80,7 +82,7 @@ export default function Login() {
- +
@@ -98,7 +100,7 @@ export default function Login() {
- Forgot password? + {t('login.forgot_password')}
@@ -110,18 +112,18 @@ export default function Login() { {isLoading ? ( <> - Signing in... + {t('login.signing_in')} ) : ( - 'Sign in' + t('login.submit') )}

- Don't have an account?{' '} + {t('login.no_account')}{' '} - Sign up + {t('login.signup')}

diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index 478ccf3..642fec9 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,16 +1,18 @@ import { Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' export default function NotFound() { + const { t } = useTranslation() return (

404

-

Page not found

+

{t('not_found.title')}

- Sorry, we couldn't find the page you're looking for. It might have been moved or deleted. + {t('not_found.subtitle')}

- Go back home + {t('not_found.back_home')}
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index adee7f0..454ba28 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,11 +1,13 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { toast } from 'react-hot-toast' +import { useTranslation } from 'react-i18next' import { apiClient } from '../api/axios' import type { ApiResponse, RegisterResponse } from '../types' import { Mail, Lock, AlertCircle, Loader2, User } from 'lucide-react' export default function Register() { + const { t } = useTranslation() const [name, setName] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -18,7 +20,7 @@ export default function Register() { e.preventDefault() if (password !== confirmPassword) { - setError('Passwords do not match') + setError(t('register.error_password_match')) return } @@ -33,18 +35,16 @@ export default function Register() { }) if (data.success) { - // Redirect to email verification page - toast.success('Registration successful! Please check your email for verification code.') + toast.success(t('register.success_msg')) navigate(`/verify-email?email=${encodeURIComponent(email)}`) } else { - setError(data.message || 'Registration failed') - toast.error(data.message || 'Registration failed') + setError(data.message || t('common.error')) + toast.error(data.message || t('common.error')) } } catch (err: any) { - setError( - err.response?.data?.message || 'An error occurred during registration. Please try again.' - ) - toast.error('Registration failed') + const message = err.response?.data?.message || t('common.error') + setError(message) + toast.error(t('common.error')) } finally { setIsLoading(false) } @@ -53,8 +53,8 @@ export default function Register() { return (
-

Create an account

-

Join LinkForge to manage your shortened URLs.

+

{t('register.title')}

+

{t('register.subtitle')}

{error && ( @@ -66,7 +66,7 @@ export default function Register() {
- +
@@ -84,7 +84,7 @@ export default function Register() {
- +
@@ -101,7 +101,7 @@ export default function Register() {
- +
@@ -119,7 +119,7 @@ export default function Register() {
- +
@@ -144,18 +144,18 @@ export default function Register() { {isLoading ? ( <> - Creating account... + {t('register.creating')} ) : ( - 'Sign up' + t('register.submit') )}

- Already have an account?{' '} + {t('register.has_account')}{' '} - Sign in + {t('register.login')}

diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx index 3aaa426..27a896b 100644 --- a/src/pages/VerifyEmail.tsx +++ b/src/pages/VerifyEmail.tsx @@ -1,11 +1,13 @@ import { useState, useEffect } from 'react' import { useSearchParams, useNavigate } from 'react-router-dom' import { toast } from 'react-hot-toast' +import { useTranslation } from 'react-i18next' import { apiClient } from '../api/axios' import type { ApiResponse } from '../types' import { Mail, ShieldCheck, Loader2, AlertCircle, RefreshCw } from 'lucide-react' export default function VerifyEmail() { + const { t } = useTranslation() const [searchParams] = useSearchParams() const email = searchParams.get('email') || '' const [otp, setOtp] = useState('') @@ -34,14 +36,14 @@ export default function VerifyEmail() { const { data } = await apiClient.post>('/auth/verify-email', { email, otp }) if (data.success) { setSuccess(true) - toast.success('Email verified! You can now sign in.') + toast.success(t('verify_email.success_title') + ' ' + t('verify_email.success_subtitle')) setTimeout(() => navigate('/login'), 2000) } else { - setError(data.message || 'Verification failed') + setError(data.message || t('verify_email.error_default')) } } catch (err: any) { - setError(err.response?.data?.message || 'Invalid or expired OTP. Please try again.') - toast.error('Verification failed') + setError(err.response?.data?.message || t('verify_email.error_default')) + toast.error(t('verify_email.error_default')) } finally { setIsLoading(false) } @@ -53,10 +55,10 @@ export default function VerifyEmail() { setIsResending(true) try { await apiClient.post>('/auth/resend-otp', { email }) - toast.success('New verification code sent!') + toast.success(t('verify_email.resend_success')) setCooldown(60) } catch (err: any) { - toast.error(err.response?.data?.message || 'Failed to resend code') + toast.error(err.response?.data?.message || t('common.error')) } finally { setIsResending(false) } @@ -68,8 +70,8 @@ export default function VerifyEmail() {
-

Email Verified!

-

Redirecting to sign in...

+

{t('verify_email.success_title')}

+

{t('verify_email.success_subtitle')}

) } @@ -80,9 +82,9 @@ export default function VerifyEmail() {
-

Verify your email

+

{t('verify_email.title')}

- We sent a 6-digit code to {email} + {t('verify_email.subtitle', { email })}

@@ -95,7 +97,7 @@ export default function VerifyEmail() {
- + - Verifying... + {t('verify_email.verifying')} ) : ( - 'Verify Email' + t('verify_email.submit') )} @@ -131,7 +133,7 @@ export default function VerifyEmail() { className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-500 disabled:text-gray-400 disabled:cursor-not-allowed transition-colors" > - {cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'} + {cooldown > 0 ? t('verify_email.resend_cooldown', { count: cooldown }) : t('verify_email.resend_btn')}
diff --git a/src/pages/VipUpgrade.tsx b/src/pages/VipUpgrade.tsx index ae42c6f..897ceae 100644 --- a/src/pages/VipUpgrade.tsx +++ b/src/pages/VipUpgrade.tsx @@ -4,62 +4,64 @@ import type { ApiResponse } from '../types' import { useAuthStore } from '../store/useAuthStore' import { Crown, CheckCircle2, Shield, Zap, Info, Loader2, AlertCircle, Star, Sparkles, Clock } from 'lucide-react' import { Navigate } from 'react-router-dom' - -const PACKAGES = [ - { - code: 'VIP_1_MONTH', - name: '1 Month', - price: 50000, - perMonth: 50000, - duration: '30 days', - badge: null, - popular: false, - }, - { - code: 'VIP_3_MONTHS', - name: '3 Months', - price: 135000, - perMonth: 45000, - duration: '90 days', - badge: 'Save 10%', - popular: true, - }, - { - code: 'VIP_1_YEAR', - name: '1 Year', - price: 450000, - perMonth: 37500, - duration: '365 days', - badge: 'Save 25%', - popular: false, - }, -] as const - -const FEATURES = [ - 'Bypass all ad interstitials automatically', - 'Unlimited short links creation', - 'Priority email support 24/7', - 'Advanced link analytics (Coming soon)', -] - -function formatDateTime(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('vi-VN', { - day: '2-digit', month: '2-digit', year: 'numeric', - hour: '2-digit', minute: '2-digit', second: '2-digit', - }) -} - -function getRemainingDays(expiresAt: string): number { - const diffMs = new Date(expiresAt).getTime() - Date.now() - return Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24))) -} +import { useTranslation } from 'react-i18next' export default function VipUpgrade() { + const { t, i18n } = useTranslation() const { user, setAuth } = useAuthStore() const [selectedPackage, setSelectedPackage] = useState('VIP_3_MONTHS') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') + const PACKAGES = [ + { + code: 'VIP_1_MONTH', + name: t('vip.packages.1_month'), + price: 50000, + perMonth: 50000, + duration: t('vip.packages.duration', { count: 30 }), + badge: null, + popular: false, + }, + { + code: 'VIP_3_MONTHS', + name: t('vip.packages.3_months'), + price: 135000, + perMonth: 45000, + duration: t('vip.packages.duration', { count: 90 }), + badge: t('vip.packages.save', { percent: 10 }), + popular: true, + }, + { + code: 'VIP_1_YEAR', + name: t('vip.packages.1_year'), + price: 450000, + perMonth: 37500, + duration: t('vip.packages.duration', { count: 365 }), + badge: t('vip.packages.save', { percent: 25 }), + popular: false, + }, + ] as const + + const FEATURES = [ + t('vip.benefits.feature_ads'), + t('vip.benefits.feature_links'), + t('vip.benefits.feature_support'), + t('vip.benefits.feature_analytics'), + ] + + function formatDateTime(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(i18n.language === 'en' ? 'en-US' : 'vi-VN', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }) + } + + function getRemainingDays(expiresAt: string): number { + const diffMs = new Date(expiresAt).getTime() - Date.now() + return Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24))) + } + // Refresh user data from backend on mount to get latest vipExpiresAt useEffect(() => { const fetchProfile = async () => { @@ -100,24 +102,24 @@ export default function VipUpgrade() { if (data.success && data.data) { window.location.href = data.data } else { - setError(data.message || 'Failed to initialize payment.') + setError(data.message || t('vip.checkout.error_init')) } } catch (err: any) { - setError(err.response?.data?.message || 'An error occurred while creating payment link.') + setError(err.response?.data?.message || t('vip.checkout.error_default')) } finally { setIsLoading(false) } } const formatPrice = (price: number) => { - return price.toLocaleString('vi-VN') + return price.toLocaleString(i18n.language === 'en' ? 'en-US' : 'vi-VN') } return (
-

Upgrade Your Experience

-

Get exclusive features, bypass ads on all your short links, and supercharge your LinkForge account.

+

{t('vip.title')}

+

{t('vip.subtitle')}

{/* VIP Status Banner */} @@ -127,16 +129,16 @@ export default function VipUpgrade() {
-

VIP Active

+

{t('vip.status.active')}

{user.vipExpiresAt ? ( - Expires: {formatDateTime(user.vipExpiresAt)} - ({getRemainingDays(user.vipExpiresAt)} days remaining) + {t('vip.status.expires', { date: formatDateTime(user.vipExpiresAt) })} + {t('vip.status.remaining', { count: getRemainingDays(user.vipExpiresAt) })} ) : ( - Permanent — No expiration + {t('vip.status.permanent')} )}
@@ -168,7 +170,7 @@ export default function VipUpgrade() { {pkg.popular && ( - Popular + {t('vip.packages.popular')} )} @@ -181,11 +183,11 @@ export default function VipUpgrade() {
{formatPrice(pkg.price)} - VND + {t('vip.checkout.currency')}
{pkg.code !== 'VIP_1_MONTH' && (

- ~{formatPrice(pkg.perMonth)} VND / month + {t('vip.packages.per_month', { price: formatPrice(pkg.perMonth) })}

)}
@@ -200,7 +202,7 @@ export default function VipUpgrade() { )}
- {isSelected ? 'Selected' : 'Select this plan'} + {isSelected ? t('vip.packages.selected') : t('vip.packages.select_plan')}
@@ -218,7 +220,7 @@ export default function VipUpgrade() {
- VIP Benefits + {t('vip.benefits.title')}

LinkForge VIP

@@ -239,22 +241,22 @@ export default function VipUpgrade() {
-

Permanent VIP!

-

You have lifetime access to all premium features. No renewal needed.

- +

{t('vip.status.permanent_title')}

+

{t('vip.status.permanent_desc')}

+
) : (

- {user.vip ? 'Extend your VIP' : 'Ready to upgrade?'} + {user.vip ? t('vip.status.extend_title') : t('vip.status.upgrade_ready')}

-

Secure payment powered by VNPay.

+

{t('vip.checkout.vnpay_desc')}

-

You pay

+

{t('vip.checkout.you_pay')}

- {formatPrice(PACKAGES.find(p => p.code === selectedPackage)!.price)} VND + {formatPrice(PACKAGES.find(p => p.code === selectedPackage)!.price)} {t('vip.checkout.currency')}

{PACKAGES.find(p => p.code === selectedPackage)!.name} @@ -270,10 +272,10 @@ export default function VipUpgrade() { {isLoading ? ( <> - Processing... + {t('vip.checkout.processing')} ) : ( - user.vip ? 'Extend with VNPay' : 'Upgrade with VNPay' + user.vip ? t('vip.checkout.extend_btn') : t('vip.checkout.upgrade_btn') )} @@ -289,7 +291,7 @@ export default function VipUpgrade() {

-

Your subscription helps keep LinkForge running and allows us to develop new features. Payments are securely processed through the VNPay gateway. Subscriptions can be canceled at any time.

+

{t('vip.info')}

)
IDUsernameEmailRoleVIP AccessExpiration{t('admin.table.id')}{t('admin.table.username')}{t('admin.table.email')}{t('admin.table.role')}{t('admin.table.vip_access')}{t('admin.table.expiration')}
- Loading users... + {t('admin.loading_users')}
- No users found. + {t('admin.no_users')}