Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 101 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/api/axios.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions src/components/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={toggleLanguage}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors text-sm font-medium text-gray-700"
title={i18n.language === 'en' ? 'Switch to Vietnamese' : 'Chuyển sang Tiếng Anh'}
>
<Globe className="w-4 h-4 text-primary-600" />
<span>{i18n.language === 'en' ? 'EN' : 'VI'}</span>
</button>
)
}
26 changes: 26 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion src/layouts/AuthLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 <Navigate to="/dashboard" replace />
Expand All @@ -16,11 +19,14 @@ export default function AuthLayout() {
<Link to="/" className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-indigo-600 inline-block">
LinkForge
</Link>
<p className="text-gray-500 mt-2">Manage your links in one place</p>
<p className="text-gray-500 mt-2">{t('login.manage_links')}</p>
</div>
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
<Outlet />
</div>
<div className="mt-8 flex justify-center">
<LanguageSwitcher />
</div>
</div>
<Toaster position="bottom-right" />
</div>
Expand Down
21 changes: 14 additions & 7 deletions src/layouts/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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"
>
<LayoutDashboard className="w-6 h-6 md:w-5 md:h-5 text-gray-500" />
<span className="text-[10px] md:text-sm">Dashboard</span>
<span className="text-[10px] md:text-sm">{t('dashboard.sidebar.dashboard')}</span>
</Link>

{user?.role !== 'ADMIN' && (
Expand All @@ -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"
>
<Star className="w-6 h-6 md:w-5 md:h-5 text-amber-500" />
<span className="text-[10px] md:text-sm">Upgrade VIP</span>
<span className="text-[10px] md:text-sm">{t('dashboard.sidebar.upgrade_vip')}</span>
</Link>
)}

Expand All @@ -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"
>
<ShieldCheck className="w-6 h-6 md:w-5 md:h-5 text-purple-500" />
<span className="text-[10px] md:text-sm">Admin Panel</span>
<span className="text-[10px] md:text-sm">{t('dashboard.sidebar.admin_panel')}</span>
</Link>
)}

Expand All @@ -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 className="w-5 h-5" />
<span>Logout</span>
<span>{t('dashboard.sidebar.logout')}</span>
</button>
</nav>
</aside>
Expand All @@ -85,19 +88,23 @@ export default function DashboardLayout() {
<div>
{!(location.pathname.includes('/vip-upgrade') || location.pathname.includes('/payment-success')) && (
<>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500">Welcome back, {user?.name}</p>
<h1 className="text-2xl font-bold text-gray-900">{t('dashboard.sidebar.dashboard')}</h1>
<p className="text-sm text-gray-500">{t('dashboard.welcome_back', { name: user?.name })}</p>
</>
)}
</div>

<div className="flex items-center gap-3">
{user?.vip && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-amber-400 to-amber-500 text-white shadow-sm shadow-amber-200">
<Star className="w-3.5 h-3.5 fill-current" /> VIP Active
<Star className="w-3.5 h-3.5 fill-current" /> {t('dashboard.vip_active')}
</span>
)}

<div className="flex items-center gap-2 border-l border-gray-200 pl-3 ml-1">
<LanguageSwitcher />
</div>

<button
onClick={handleLogout}
className="md:hidden p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
Expand Down
Loading
Loading