feat(i18n): add multi-language support (en/zh) across app and server

Implement useLocale composable and shared translation dictionaries
Translate public pages, booking flow, and receipt views
Store booking locale to send localized WhatsApp notifications
This commit is contained in:
2026-05-08 15:31:44 +08:00
parent b05cfd2c0e
commit 1318e766d5
14 changed files with 789 additions and 209 deletions

View File

@@ -40,7 +40,7 @@
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
Dinner Ticket System
{{ t('layout.brand') }}
</div>
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
@@ -105,7 +105,7 @@
<UButton
to="/"
label="Public"
:label="t('common.public')"
color="neutral"
variant="ghost"
icon="i-lucide-house"
@@ -113,9 +113,19 @@
@click="mobileMenuOpen = false"
/>
<UButton
:label="currentLocaleOption.label"
color="neutral"
variant="ghost"
icon="i-lucide-languages"
class="w-full justify-start rounded-2xl"
:aria-label="t('common.switchLanguage')"
@click="toggleLocale"
/>
<UButton
:loading="logoutPending"
label="Logout"
:label="t('common.logout')"
color="neutral"
variant="outline"
icon="i-lucide-log-out"
@@ -136,7 +146,7 @@
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
Dinner Ticket System
{{ t('layout.brand') }}
</div>
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
@@ -196,16 +206,26 @@
<div class="grid gap-2">
<UButton
to="/"
label="Public"
:label="t('common.public')"
color="neutral"
variant="ghost"
icon="i-lucide-house"
class="justify-start rounded-2xl"
/>
<UButton
:label="currentLocaleOption.label"
color="neutral"
variant="ghost"
icon="i-lucide-languages"
class="justify-start rounded-2xl"
:aria-label="t('common.switchLanguage')"
@click="toggleLocale"
/>
<UButton
:loading="logoutPending"
label="Logout"
:label="t('common.logout')"
color="neutral"
variant="outline"
icon="i-lucide-log-out"
@@ -227,7 +247,7 @@
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
Dinner Ticket System
{{ t('layout.brand') }}
</div>
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
@@ -239,7 +259,7 @@
color="neutral"
variant="outline"
class="shrink-0 rounded-full"
:label="mobileMenuOpen ? 'Close' : 'Menu'"
:label="mobileMenuOpen ? t('common.close') : t('common.menu')"
:icon="mobileMenuOpen ? 'i-lucide-x' : 'i-lucide-menu'"
:aria-expanded="mobileMenuOpen"
aria-label="Toggle navigation"
@@ -255,7 +275,7 @@
<footer class="border-t border-default bg-default/96">
<UContainer class="py-5 text-center text-sm text-muted">
© 2026 DAP 60th Anniversary Committee. All rights reserved.
{{ t('layout.footer') }}
</UContainer>
</footer>
</div>
@@ -273,20 +293,32 @@
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.24em] text-muted">
Dinner Ticket System
{{ t('layout.brand') }}
</div>
</div>
</div>
<UButton
id="loginBtn"
:to="route.path.startsWith('/login') ? '/' : '/login'"
:label="route.path.startsWith('/login') ? 'Back' : 'Login'"
color="neutral"
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
class="rounded-full"
/>
<div class="flex items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-languages"
:label="currentLocaleOption.shortLabel"
class="rounded-full"
:aria-label="t('common.switchLanguage')"
@click="toggleLocale"
/>
<UButton
id="loginBtn"
:to="route.path.startsWith('/login') ? '/' : '/login'"
:label="route.path.startsWith('/login') ? t('common.back') : t('common.login')"
color="neutral"
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
class="rounded-full"
/>
</div>
</div>
</UContainer>
</header>
@@ -297,7 +329,7 @@
<footer class="border-t border-default bg-default/96">
<UContainer class="py-5 text-center text-sm text-muted">
© 2026 DAP 60th Anniversary Committee. All rights reserved.
{{ t('layout.footer') }}
</UContainer>
</footer>
</template>
@@ -308,7 +340,7 @@
import { getErrorMessage } from '../utils/errors'
interface SystemMenuItem {
label: string
labelKey: 'nav.bookings' | 'nav.security' | 'nav.users'
to: string
icon: string
requiresSuperAdmin?: boolean
@@ -320,26 +352,33 @@ const router = useRouter()
const toast = useToast()
const auth = useAuth()
const apiClient = useApiClient()
const { currentLocaleOption, locale, t, toggleLocale } = useLocale()
const logoutPending = ref(false)
const mobileMenuOpen = ref(false)
await auth.fetchSession()
useHead(() => ({
htmlAttrs: {
lang: locale.value === 'zh' ? 'zh-Hans' : 'en'
}
}))
const allSystemMenuItems: SystemMenuItem[] = [
{
label: 'Bookings',
labelKey: 'nav.bookings',
to: '/bookings',
icon: 'i-lucide-clipboard-list',
matches: (path) => path.startsWith('/bookings')
},
{
label: 'Security',
labelKey: 'nav.security',
to: '/security',
icon: 'i-lucide-shield-check',
matches: (path) => path.startsWith('/security')
},
{
label: 'Users',
labelKey: 'nav.users',
to: '/management/users',
icon: 'i-lucide-users',
requiresSuperAdmin: true,
@@ -348,13 +387,18 @@ const allSystemMenuItems: SystemMenuItem[] = [
]
const systemMenuItems = computed(() => {
return allSystemMenuItems.filter((item) => {
return !item.requiresSuperAdmin || auth.isSuperAdmin.value
})
return allSystemMenuItems
.filter((item) => {
return !item.requiresSuperAdmin || auth.isSuperAdmin.value
})
.map((item) => ({
...item,
label: t(item.labelKey)
}))
})
const userRoleLabel = computed(() => {
return auth.isSuperAdmin.value ? 'Super Admin' : 'Staff'
return auth.isSuperAdmin.value ? t('role.superAdmin') : t('role.staff')
})
function isMenuItemActive(item: SystemMenuItem) {
@@ -405,15 +449,15 @@ async function logout() {
await router.push('/login')
toast.add({
title: 'Signed out',
description: 'Your session has been cleared.',
title: t('layout.signedOut'),
description: t('layout.sessionCleared'),
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
toast.add({
title: 'Logout failed',
description: getErrorMessage(error, 'Unable to end the current session.'),
title: t('layout.logoutFailed'),
description: getErrorMessage(error, t('layout.logoutFailedDescription')),
color: 'error',
icon: 'i-lucide-circle-alert'
})