Implement useLocale composable and shared translation dictionaries Translate public pages, booking flow, and receipt views Store booking locale to send localized WhatsApp notifications
469 lines
16 KiB
Vue
469 lines
16 KiB
Vue
<template>
|
|
<div class="relative min-h-dvh bg-default text-default">
|
|
<div class="pointer-events-none absolute inset-x-0 top-0 h-72 bg-gradient-to-b from-primary/10 via-primary/0 to-transparent opacity-80" />
|
|
|
|
<template v-if="auth.user.value">
|
|
<Transition
|
|
enter-active-class="transition-opacity duration-200 ease-out"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition-opacity duration-150 ease-in"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<button
|
|
v-if="mobileMenuOpen"
|
|
type="button"
|
|
class="fixed inset-0 z-40 bg-black/45 backdrop-blur-[2px] lg:hidden"
|
|
aria-label="Close navigation"
|
|
@click="mobileMenuOpen = false"
|
|
/>
|
|
</Transition>
|
|
|
|
<Transition
|
|
enter-active-class="transform transition duration-300 ease-out"
|
|
enter-from-class="-translate-x-6 opacity-0"
|
|
enter-to-class="translate-x-0 opacity-100"
|
|
leave-active-class="transform transition duration-200 ease-in"
|
|
leave-from-class="translate-x-0 opacity-100"
|
|
leave-to-class="-translate-x-6 opacity-0"
|
|
>
|
|
<aside
|
|
v-if="mobileMenuOpen"
|
|
class="fixed inset-y-2 left-2 z-50 flex w-[min(20rem,calc(100vw-1rem))] flex-col rounded-[28px] border border-default/80 bg-default/96 p-3 shadow-2xl backdrop-blur-xl lg:hidden"
|
|
>
|
|
<div class="flex items-start justify-between gap-3 rounded-3xl border border-default bg-gradient-to-br from-primary/12 via-default to-default px-4 py-4">
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/12 text-primary">
|
|
<UIcon name="i-lucide-grid-2x2" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
|
{{ t('layout.brand') }}
|
|
</div>
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-x"
|
|
class="rounded-full"
|
|
aria-label="Close navigation"
|
|
@click="mobileMenuOpen = false"
|
|
/>
|
|
</div>
|
|
|
|
<nav class="mt-4 grid gap-2">
|
|
<NuxtLink
|
|
v-for="item in systemMenuItems"
|
|
:key="item.to"
|
|
:to="item.to"
|
|
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
|
|
class="group flex min-h-14 items-center gap-3 rounded-2xl border px-4 py-3 text-sm font-medium transition-all duration-200"
|
|
:class="mobileMenuItemClasses(item)"
|
|
>
|
|
<div
|
|
class="flex size-10 shrink-0 items-center justify-center rounded-2xl transition-colors duration-200"
|
|
:class="mobileMenuIconClasses(item)"
|
|
>
|
|
<UIcon :name="item.icon" class="size-5" />
|
|
</div>
|
|
<span class="min-w-0 truncate">{{ item.label }}</span>
|
|
<UIcon name="i-lucide-chevron-right" class="ml-auto size-4 opacity-50 transition-opacity duration-200 group-hover:opacity-100" />
|
|
</NuxtLink>
|
|
</nav>
|
|
|
|
<div class="mt-auto space-y-3 border-t border-default pt-3">
|
|
<div class="rounded-3xl border border-default bg-default/90 px-4 py-4 shadow-sm">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-highlighted">
|
|
<UIcon name="i-lucide-user-round" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
<div class="truncate text-sm text-muted">
|
|
@{{ auth.user.value.username }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UBadge
|
|
:label="userRoleLabel"
|
|
color="primary"
|
|
variant="soft"
|
|
class="mt-3 rounded-full"
|
|
/>
|
|
</div>
|
|
|
|
<UButton
|
|
to="/"
|
|
:label="t('common.public')"
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-house"
|
|
class="w-full justify-start rounded-2xl"
|
|
@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="t('common.logout')"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-log-out"
|
|
class="w-full justify-start rounded-2xl"
|
|
@click="logout"
|
|
/>
|
|
</div>
|
|
</aside>
|
|
</Transition>
|
|
|
|
<div class="relative lg:grid lg:min-h-dvh lg:grid-cols-[17.5rem_minmax(0,1fr)]">
|
|
<aside class="sticky top-0 hidden h-dvh flex-col border-r border-default/80 bg-default/92 px-5 py-5 backdrop-blur-xl lg:flex">
|
|
<div class="rounded-[28px] border border-default bg-gradient-to-br from-primary/12 via-default to-default px-4 py-4 shadow-sm">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary/12 text-primary">
|
|
<UIcon name="i-lucide-grid-2x2" class="size-6" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
|
{{ t('layout.brand') }}
|
|
</div>
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<nav class="mt-6 grid gap-2">
|
|
<NuxtLink
|
|
v-for="item in systemMenuItems"
|
|
:key="item.to"
|
|
:to="item.to"
|
|
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
|
|
class="group flex min-h-14 items-center gap-3 rounded-2xl border px-4 py-3 text-sm font-medium transition-all duration-200"
|
|
:class="desktopMenuItemClasses(item)"
|
|
>
|
|
<div
|
|
class="flex size-10 shrink-0 items-center justify-center rounded-2xl transition-colors duration-200"
|
|
:class="desktopMenuIconClasses(item)"
|
|
>
|
|
<UIcon :name="item.icon" class="size-5" />
|
|
</div>
|
|
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
|
|
<div
|
|
class="h-2.5 w-2.5 rounded-full transition-all duration-200"
|
|
:class="isMenuItemActive(item) ? 'bg-primary shadow-[0_0_0_4px_rgba(var(--ui-primary-rgb),0.14)]' : 'bg-default group-hover:bg-primary/30'"
|
|
/>
|
|
</NuxtLink>
|
|
</nav>
|
|
|
|
<div class="mt-auto space-y-3">
|
|
<div class="rounded-[28px] border border-default bg-default/90 px-4 py-4 shadow-sm">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-highlighted">
|
|
<UIcon name="i-lucide-user-round" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
<div class="truncate text-sm text-muted">
|
|
@{{ auth.user.value.username }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UBadge
|
|
:label="userRoleLabel"
|
|
color="primary"
|
|
variant="soft"
|
|
class="mt-3 rounded-full"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid gap-2">
|
|
<UButton
|
|
to="/"
|
|
: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="t('common.logout')"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-log-out"
|
|
class="justify-start rounded-2xl"
|
|
@click="logout"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="min-w-0">
|
|
<header class="sticky top-0 z-30 border-b border-default/80 bg-default/92 backdrop-blur-xl lg:hidden">
|
|
<UContainer class="py-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="flex min-w-0 items-center gap-3 rounded-2xl border border-default bg-default/80 px-3 py-2 shadow-sm">
|
|
<div class="flex size-10 shrink-0 items-center justify-center rounded-2xl bg-primary/12 text-primary">
|
|
<UIcon name="i-lucide-grid-2x2" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
|
{{ t('layout.brand') }}
|
|
</div>
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
class="shrink-0 rounded-full"
|
|
:label="mobileMenuOpen ? t('common.close') : t('common.menu')"
|
|
:icon="mobileMenuOpen ? 'i-lucide-x' : 'i-lucide-menu'"
|
|
:aria-expanded="mobileMenuOpen"
|
|
aria-label="Toggle navigation"
|
|
@click="mobileMenuOpen = !mobileMenuOpen"
|
|
/>
|
|
</div>
|
|
</UContainer>
|
|
</header>
|
|
|
|
<UMain class="relative z-10">
|
|
<slot />
|
|
</UMain>
|
|
|
|
<footer class="border-t border-default bg-default/96">
|
|
<UContainer class="py-5 text-center text-sm text-muted">
|
|
{{ t('layout.footer') }}
|
|
</UContainer>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<header class="relative border-b border-default/80 bg-default/92 backdrop-blur-xl">
|
|
<UContainer class="py-4 md:py-5">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="flex min-w-0 items-center gap-3 rounded-2xl border border-default bg-default/80 px-3 py-2 shadow-sm">
|
|
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
<UIcon name="i-lucide-grid-2x2" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-xs font-semibold uppercase tracking-[0.24em] text-muted">
|
|
{{ t('layout.brand') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<UMain class="relative z-10">
|
|
<slot />
|
|
</UMain>
|
|
|
|
<footer class="border-t border-default bg-default/96">
|
|
<UContainer class="py-5 text-center text-sm text-muted">
|
|
{{ t('layout.footer') }}
|
|
</UContainer>
|
|
</footer>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { getErrorMessage } from '../utils/errors'
|
|
|
|
interface SystemMenuItem {
|
|
labelKey: 'nav.bookings' | 'nav.security' | 'nav.users'
|
|
to: string
|
|
icon: string
|
|
requiresSuperAdmin?: boolean
|
|
matches: (path: string) => boolean
|
|
}
|
|
|
|
const route = useRoute()
|
|
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[] = [
|
|
{
|
|
labelKey: 'nav.bookings',
|
|
to: '/bookings',
|
|
icon: 'i-lucide-clipboard-list',
|
|
matches: (path) => path.startsWith('/bookings')
|
|
},
|
|
{
|
|
labelKey: 'nav.security',
|
|
to: '/security',
|
|
icon: 'i-lucide-shield-check',
|
|
matches: (path) => path.startsWith('/security')
|
|
},
|
|
{
|
|
labelKey: 'nav.users',
|
|
to: '/management/users',
|
|
icon: 'i-lucide-users',
|
|
requiresSuperAdmin: true,
|
|
matches: (path) => path.startsWith('/management/users')
|
|
}
|
|
]
|
|
|
|
const systemMenuItems = computed(() => {
|
|
return allSystemMenuItems
|
|
.filter((item) => {
|
|
return !item.requiresSuperAdmin || auth.isSuperAdmin.value
|
|
})
|
|
.map((item) => ({
|
|
...item,
|
|
label: t(item.labelKey)
|
|
}))
|
|
})
|
|
|
|
const userRoleLabel = computed(() => {
|
|
return auth.isSuperAdmin.value ? t('role.superAdmin') : t('role.staff')
|
|
})
|
|
|
|
function isMenuItemActive(item: SystemMenuItem) {
|
|
return item.matches(route.path)
|
|
}
|
|
|
|
function desktopMenuItemClasses(item: SystemMenuItem) {
|
|
return isMenuItemActive(item)
|
|
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
|
|
: 'border-default bg-default/80 text-default hover:border-primary/20 hover:bg-muted/60 hover:text-highlighted'
|
|
}
|
|
|
|
function desktopMenuIconClasses(item: SystemMenuItem) {
|
|
return isMenuItemActive(item)
|
|
? 'bg-primary/15 text-primary'
|
|
: 'bg-muted text-muted group-hover:bg-primary/10 group-hover:text-primary'
|
|
}
|
|
|
|
function mobileMenuItemClasses(item: SystemMenuItem) {
|
|
return isMenuItemActive(item)
|
|
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
|
|
: 'border-default bg-default/88 text-default'
|
|
}
|
|
|
|
function mobileMenuIconClasses(item: SystemMenuItem) {
|
|
return isMenuItemActive(item)
|
|
? 'bg-primary/15 text-primary'
|
|
: 'bg-muted text-muted'
|
|
}
|
|
|
|
watch(() => route.path, () => {
|
|
mobileMenuOpen.value = false
|
|
})
|
|
|
|
async function logout() {
|
|
if (logoutPending.value) {
|
|
return
|
|
}
|
|
|
|
logoutPending.value = true
|
|
|
|
try {
|
|
await apiClient('/api/auth/logout', {
|
|
method: 'POST'
|
|
})
|
|
|
|
auth.clearUser()
|
|
await router.push('/login')
|
|
|
|
toast.add({
|
|
title: t('layout.signedOut'),
|
|
description: t('layout.sessionCleared'),
|
|
color: 'success',
|
|
icon: 'i-lucide-check-circle-2'
|
|
})
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: t('layout.logoutFailed'),
|
|
description: getErrorMessage(error, t('layout.logoutFailedDescription')),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
logoutPending.value = false
|
|
}
|
|
}
|
|
</script>
|