refactor(layouts): merge portal navbar/drawer into PortalLayout.vue

Migrates the navbar (event/platform two-mode toggle), mobile drawer
with avatar header + logout, RouterView Suspense wrapper, and footer
from apps/portal/src/layouts/portal.vue into the PortalLayout.vue
skeleton from PR-A. The skeleton's structure (VApp / VAppBar / VMain
/ VFooter) is preserved as the outer shell.

Notable adaptations:
  - useAuthStore → usePortalAuthStore (renamed in C.3)
  - usePortalStore import path → @/stores/portal/usePortalStore
  - mobile nav links now point at /portal/evenementen and /portal/profiel
    (the new sub-zone paths) instead of /evenementen and /profiel
  - explicit `import { useRoute, useRouter }` from vue-router so the
    vitest mock can intercept (auto-import not configured for these in
    the trimmed test config)

Updated PortalLayout.spec.ts to mock the two pinia stores plus
useSkins, vue-router, UserAvatarMenu, and AppLoadingIndicator. Tests
now assert the auth-conditional rendering: header + drawer hidden
when unauthenticated, main + footer always present.

Also pulls in the @form-schema → @/composables/forms/* import
rewrites in the C.4-moved composables that the previous commit's
rename-only diff left unstaged.

Vitest: 23 files / 162 tests, no errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:11:58 +02:00
parent 7282861a7e
commit e3452312d1
8 changed files with 278 additions and 31 deletions

View File

@@ -1,32 +1,232 @@
<script setup lang="ts">
// Portal layout skeleton — WS-3 session 1a.
//
// This is a foundation file. Content migration from apps/portal/ is a
// later WS-3 session. The shape (top-bar / main / footer) is fixed
// here so the router consolidation can target it with a stable name.
//
// DO NOT add nav, branding, or auth logic in this file directly.
// Future sessions will compose those in via slots or child components.
// Portal layout — content migrated from apps/portal/src/layouts/portal.vue
// during WS-3 PR-B1 (single-SPA consolidation). Used by every page
// under /portal/** plus selected non-auth portal entry points
// (registreren, wachtwoord-instellen). Authenticated portal users see
// the navbar + mobile drawer; unauthenticated visits render only the
// main content + footer.
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
import { useRoute, useRouter } from 'vue-router'
const { injectSkinClasses } = useSkins()
injectSkinClasses()
const authStore = usePortalAuthStore()
const portal = usePortalStore()
const route = useRoute()
const router = useRouter()
const isMobileMenuOpen = ref(false)
// Navbar mode: 'event' shows org name + event name + back link.
// Default ('platform') shows Crewli logo + page title.
const isEventMode = computed(() => route.meta.navMode === 'event')
const navTitle = computed(() => route.meta.navTitle)
const eventName = computed(() => portal.activeEvent?.event_name ?? '')
const orgName = computed(() => portal.activeEvent?.organisation_name ?? '')
const mobileNavItems = computed(() => [
{ title: 'Mijn evenementen', to: '/portal/evenementen', icon: 'tabler-calendar-event' },
{ title: 'Mijn Profiel', to: '/portal/profiel', icon: 'tabler-user' },
])
const isFallbackStateActive = ref(false)
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
watch([isFallbackStateActive, refLoadingIndicator], () => {
if (isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.fallbackHandle()
if (!isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.resolveHandle()
}, { immediate: true })
async function logout() {
isMobileMenuOpen.value = false
await authStore.logout()
await router.push('/login')
}
</script>
<template>
<VApp>
<AppLoadingIndicator ref="refLoadingIndicator" />
<!-- Navbar: only shown when authenticated -->
<VAppBar
density="compact"
v-if="authStore.isAuthenticated"
flat
color="surface"
border="b"
height="64"
>
<!-- Logo + portal nav land here in a later session -->
<VContainer
fluid
class="d-flex align-center py-0"
style="max-inline-size: 1440px;"
>
<!-- Event mode: Org name + Event name + Back link -->
<template v-if="isEventMode">
<div class="d-flex align-center gap-x-2 flex-shrink-0">
<VIcon
icon="tabler-building"
size="24"
color="primary"
/>
<span
v-if="orgName"
class="text-subtitle-1 font-weight-medium text-high-emphasis d-none d-sm-inline text-truncate"
style="max-width: 200px;"
>
{{ orgName }}
</span>
</div>
<span
v-if="eventName"
class="text-body-1 text-medium-emphasis ms-2 text-truncate d-none d-sm-inline"
style="max-width: 250px;"
>
{{ eventName }}
</span>
<VBtn
variant="text"
size="small"
color="default"
class="text-medium-emphasis ms-2 d-none d-md-flex"
to="/portal/evenementen"
>
<VIcon
start
icon="tabler-arrow-left"
size="16"
/>
Evenementen
</VBtn>
</template>
<!-- Platform mode: Crewli logo + optional page title -->
<template v-else>
<RouterLink
to="/portal/evenementen"
class="d-flex align-center gap-x-2 text-decoration-none flex-shrink-0"
>
<VIcon
icon="tabler-users-group"
size="26"
color="primary"
/>
<span class="text-h6 font-weight-bold text-high-emphasis d-none d-sm-inline">
Crewli
</span>
</RouterLink>
<span
v-if="navTitle"
class="text-body-1 text-medium-emphasis ms-4 d-none d-md-inline"
>
{{ navTitle }}
</span>
</template>
<VSpacer />
<!-- Right section: Avatar menu (desktop) -->
<div class="d-none d-md-flex align-center">
<UserAvatarMenu />
</div>
<!-- Mobile nav toggle -->
<VAppBarNavIcon
class="d-md-none"
@click="isMobileMenuOpen = !isMobileMenuOpen"
/>
</VContainer>
</VAppBar>
<!-- Mobile navigation drawer -->
<VNavigationDrawer
v-if="authStore.isAuthenticated"
v-model="isMobileMenuOpen"
temporary
location="end"
class="d-md-none"
>
<div class="pa-4 pb-2">
<div class="d-flex align-center gap-3">
<VAvatar
size="40"
color="primary"
>
<span class="text-body-2 font-weight-medium text-white">
{{ (authStore.user?.first_name?.charAt(0) ?? '') + (authStore.user?.last_name?.charAt(0) ?? '') }}
</span>
</VAvatar>
<div class="min-w-0">
<div class="text-body-1 font-weight-bold text-truncate">
{{ authStore.user?.full_name }}
</div>
<div class="text-caption text-medium-emphasis text-truncate">
{{ authStore.user?.email }}
</div>
</div>
</div>
</div>
<VDivider />
<VList nav>
<VListItem
v-for="item in mobileNavItems"
:key="item.to"
:to="item.to"
:prepend-icon="item.icon"
:title="item.title"
@click="isMobileMenuOpen = false"
/>
<VDivider class="my-2" />
<VListItem
prepend-icon="tabler-logout"
title="Uitloggen"
class="text-error"
@click="logout"
/>
</VList>
</VNavigationDrawer>
<VMain>
<RouterView />
<VContainer
fluid
class="pa-4 pa-sm-6"
style="max-inline-size: 1440px;"
>
<RouterView v-slot="{ Component }">
<Suspense
:timeout="0"
@fallback="isFallbackStateActive = true"
@resolve="isFallbackStateActive = false"
>
<Component :is="Component" />
</Suspense>
</RouterView>
</VContainer>
</VMain>
<VFooter
app
class="text-caption"
color="transparent"
class="justify-center text-caption text-medium-emphasis py-3"
>
<!-- Portal footer content lands here in a later session -->
Powered by Crewli
</VFooter>
</VApp>
</template>