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
425 lines
15 KiB
Vue
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>
|