Files
dticket.tootaio.com/app/layouts/default.vue
xiaomai 8541c4a2d1 feat(bookings): implement booking system and confirmation flow
Add database tables and repository for managing bookings
Create API endpoints for booking submission and capacity management
Update landing page to persist bookings before WhatsApp redirection
2026-04-12 21:43:30 +08:00

425 lines
15 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">
Dinner Ticket System
</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="Public"
color="neutral"
variant="ghost"
icon="i-lucide-house"
class="w-full justify-start rounded-2xl"
@click="mobileMenuOpen = false"
/>
<UButton
:loading="logoutPending"
label="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">
Dinner Ticket System
</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="Public"
color="neutral"
variant="ghost"
icon="i-lucide-house"
class="justify-start rounded-2xl"
/>
<UButton
:loading="logoutPending"
label="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">
Dinner Ticket System
</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 ? 'Close' : '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">
© 2026 DAP 60th Anniversary Committee. All rights reserved.
</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">
Dinner Ticket System
</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>
</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">
© 2026 DAP 60th Anniversary Committee. All rights reserved.
</UContainer>
</footer>
</template>
</div>
</template>
<script lang="ts" setup>
import { getErrorMessage } from '../utils/errors'
interface SystemMenuItem {
label: string
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 logoutPending = ref(false)
const mobileMenuOpen = ref(false)
await auth.fetchSession()
const allSystemMenuItems: SystemMenuItem[] = [
{
label: 'Bookings',
to: '/bookings',
icon: 'i-lucide-clipboard-list',
matches: (path) => path.startsWith('/bookings')
},
{
label: 'Security',
to: '/security',
icon: 'i-lucide-shield-check',
matches: (path) => path.startsWith('/security')
},
{
label: '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
})
})
const userRoleLabel = computed(() => {
return auth.isSuperAdmin.value ? 'Super Admin' : '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: 'Signed out',
description: 'Your session has been cleared.',
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.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
logoutPending.value = false
}
}
</script>