bilhej/frontend/src/components/AppHeader.vue
Joakim Mörling 1c9269699e
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Failing after 1m50s
CI / E2E browser tests (pull_request) Failing after 1m38s
Add admin order fulfillment tracking.
Register PostNord shipments, admin notes, and guarded status transitions
with customer emails. Expandable admin UI, V11 migration, serial E2E suite,
and AGENTS.md Docker-only E2E guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:21:17 +02:00

517 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const isSettingsActive = computed(
() => route.name === 'change-email' || route.name === 'change-password',
)
const settingsOpen = ref(false)
const menuOpen = ref(false)
const settingsRef = ref<HTMLElement | null>(null)
function toggleSettings() {
settingsOpen.value = !settingsOpen.value
}
function closeSettings() {
settingsOpen.value = false
}
function toggleMenu() {
menuOpen.value = !menuOpen.value
if (!menuOpen.value) {
closeSettings()
}
}
function closeMenu() {
menuOpen.value = false
closeSettings()
}
function handleDocumentClick(event: MouseEvent) {
if (!settingsRef.value?.contains(event.target as Node)) {
settingsOpen.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu()
}
}
function handleNavClick() {
closeMenu()
}
function handleLogout() {
closeMenu()
auth.logout()
router.push('/')
}
watch(
() => route.fullPath,
() => {
closeMenu()
},
)
watch(menuOpen, (open) => {
document.body.classList.toggle('nav-menu-open', open)
})
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('keydown', handleKeydown)
document.body.classList.remove('nav-menu-open')
})
</script>
<template>
<header class="app-header" :class="{ 'app-header--menu-open': menuOpen }">
<div class="app-header__inner">
<RouterLink to="/" class="app-header__logo" @click="handleNavClick">
<svg
class="app-header__logo-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect
x="2"
y="5"
width="20"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M2 7l10 6 10-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Bilhej
</RouterLink>
<button
type="button"
class="app-header__menu-toggle"
:aria-expanded="menuOpen"
aria-controls="app-header-nav"
@click="toggleMenu"
>
<span class="visually-hidden">{{
menuOpen ? 'Stäng meny' : 'Öppna meny'
}}</span>
<svg
v-if="!menuOpen"
class="app-header__menu-icon"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M4 7h16M4 12h16M4 17h16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<svg
v-else
class="app-header__menu-icon"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<nav id="app-header-nav" class="app-header__nav">
<RouterLink to="/" class="app-header__link" @click="handleNavClick"
>Hem</RouterLink
>
<template v-if="!auth.isAuthenticated">
<RouterLink
to="/logga-in"
class="app-header__link"
@click="handleNavClick"
>Logga in</RouterLink
>
<RouterLink
to="/registrera"
class="app-header__link"
@click="handleNavClick"
>Registrera</RouterLink
>
</template>
<template v-else>
<RouterLink
v-if="auth.isAdmin"
to="/admin"
class="app-header__link"
@click="handleNavClick"
>Admin</RouterLink
>
<RouterLink
to="/orders"
class="app-header__link"
@click="handleNavClick"
>Mina beställningar</RouterLink
>
<RouterLink
to="/andra-epost"
class="app-header__link app-header__link--settings-mobile"
:class="{
'app-header__link--active-settings':
route.name === 'change-email',
}"
@click="handleNavClick"
>
Byt e-postadress
</RouterLink>
<RouterLink
to="/andra-losenord"
class="app-header__link app-header__link--settings-mobile"
:class="{
'app-header__link--active-settings':
route.name === 'change-password',
}"
@click="handleNavClick"
>
Byt lösenord
</RouterLink>
<div ref="settingsRef" class="app-header__settings">
<button
type="button"
class="app-header__settings-trigger"
:class="{
'app-header__settings-trigger--active': isSettingsActive,
}"
aria-haspopup="menu"
:aria-expanded="settingsOpen"
@click.stop="toggleSettings"
>
Inställningar
<svg
class="app-header__settings-chevron"
:class="{ 'app-header__settings-chevron--open': settingsOpen }"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.94a.75.75 0 111.08 1.04l-4.24 4.5a.75.75 0 01-1.08 0l-4.24-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>
<div
v-if="settingsOpen"
class="app-header__settings-menu"
role="menu"
>
<RouterLink
to="/andra-epost"
class="app-header__settings-item"
role="menuitem"
@click="closeSettings"
>
Byt e-postadress
</RouterLink>
<RouterLink
to="/andra-losenord"
class="app-header__settings-item"
role="menuitem"
@click="closeSettings"
>
Byt lösenord
</RouterLink>
</div>
</div>
<button class="app-header__logout" @click="handleLogout">
Logga ut
</button>
<span class="app-header__email">{{ auth.email }}</span>
</template>
</nav>
</div>
</header>
</template>
<style scoped>
.app-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(253, 250, 245, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border);
}
.app-header__inner {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
max-width: 72rem;
margin: 0 auto;
padding: 0.875rem var(--page-gutter);
}
.app-header__logo {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 1.125rem;
font-weight: 700;
color: var(--color-ink);
text-decoration: none;
flex-shrink: 0;
}
.app-header__logo-icon {
width: 1.5rem;
height: 1.5rem;
}
.app-header__menu-toggle {
display: none;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
color: var(--color-ink);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
flex-shrink: 0;
}
.app-header__menu-icon {
width: 1.375rem;
height: 1.375rem;
}
.app-header__nav {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.app-header__link {
padding: 0.4rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-muted);
text-decoration: none;
border-radius: var(--radius-full);
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.app-header__link:hover,
.app-header__link.router-link-active {
color: var(--color-primary-dark);
background: var(--color-primary-soft);
}
.app-header__link--active-settings {
color: var(--color-primary-dark);
background: var(--color-primary-soft);
}
.app-header__link--settings-mobile {
display: none;
}
.app-header__email {
color: var(--color-muted);
font-size: 0.8125rem;
padding: 0 0.5rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-header__settings {
position: relative;
}
.app-header__settings-trigger {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
cursor: pointer;
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
.app-header__settings-trigger:hover,
.app-header__settings-trigger--active,
.app-header__settings-trigger[aria-expanded='true'] {
color: var(--color-primary-dark);
border-color: #bfdbfe;
background: var(--color-primary-soft);
}
.app-header__settings-chevron {
width: 1rem;
height: 1rem;
transition: transform var(--transition-fast);
}
.app-header__settings-chevron--open {
transform: rotate(180deg);
}
.app-header__settings-menu {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 12rem;
padding: 0.35rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.app-header__settings-item {
display: block;
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-ink);
text-decoration: none;
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.app-header__settings-item:hover {
background: var(--color-border-light);
}
.app-header__logout {
background: none;
border: 1px solid var(--color-border);
color: var(--color-muted);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
padding: 0.35rem 0.875rem;
border-radius: var(--radius-full);
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
.app-header__logout:hover {
color: var(--color-danger);
border-color: var(--color-danger);
background: var(--color-danger-soft);
}
@media (max-width: 639px) {
.app-header__menu-toggle {
display: inline-flex;
}
.app-header__inner {
flex-wrap: wrap;
align-items: center;
}
.app-header__nav {
display: none;
flex-direction: column;
align-items: stretch;
width: 100%;
gap: 0.25rem;
padding: var(--space-sm) 0 var(--space-md);
border-top: 1px solid var(--color-border);
}
.app-header--menu-open .app-header__nav {
display: flex;
}
.app-header__link,
.app-header__settings-trigger,
.app-header__logout {
width: 100%;
justify-content: flex-start;
border-radius: var(--radius-md);
padding: 0.75rem 1rem;
font-size: 1rem;
min-height: 2.75rem;
}
.app-header__link--settings-mobile {
display: flex;
align-items: center;
}
.app-header__settings {
display: none;
}
.app-header__email {
order: 10;
max-width: none;
padding: var(--space-sm) 1rem 0;
font-size: 0.875rem;
text-align: center;
white-space: normal;
word-break: break-all;
}
.app-header__logout {
margin-top: var(--space-xs);
}
}
</style>
<style>
body.nav-menu-open {
overflow: hidden;
}
</style>