Make customer-facing UI usable on smartphones.
Mobile traffic was breaking on narrow viewports because the header nav overflowed and several pages used desktop-only spacing. This adds a shared phone breakpoint, a hamburger menu, and scroll-to-top on route changes so footer and menu navigation always land at the top of the page. - Add --page-gutter and max-width 639px rules in base.css - AppHeader: hamburger panel on small screens; flat account links on mobile - AppFooter: stack footer links vertically on phones - Home, compose, edit order, orders, auth, and legal pages: tighter gutters and responsive layout (orders card actions stack; home grids single-column) - Router scrollBehavior: scroll to top on navigation; restore on browser back - Tests: AppHeader menu toggle, Router scrollBehavior, mobile Playwright checks Admin page is intentionally unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
71a3225a11
commit
7a95c1423c
25 changed files with 454 additions and 57 deletions
|
|
@ -200,6 +200,40 @@ test.describe('Header auth state', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.describe('Header on mobile viewport', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } })
|
||||||
|
|
||||||
|
test('menu reveals navigation links when authenticated', async ({ page }) => {
|
||||||
|
await authenticateUser(page)
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const header = page.locator('header')
|
||||||
|
await expect(
|
||||||
|
header.getByRole('link', { name: 'Mina beställningar' }),
|
||||||
|
).not.toBeVisible()
|
||||||
|
|
||||||
|
await header.getByRole('button', { name: 'Öppna meny' }).click()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
header.getByRole('link', { name: 'Mina beställningar' }),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
header.getByRole('link', { name: 'Byt e-postadress' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('home page has no horizontal overflow', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
const scrollWidth = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth,
|
||||||
|
)
|
||||||
|
const clientWidth = await page.evaluate(
|
||||||
|
() => document.documentElement.clientWidth,
|
||||||
|
)
|
||||||
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
async function authenticateUser(page: import('@playwright/test').Page) {
|
async function authenticateUser(page: import('@playwright/test').Page) {
|
||||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ describe('AppHeader', () => {
|
||||||
const wrapper = mount(AppHeader, {
|
const wrapper = mount(AppHeader, {
|
||||||
global: { plugins: [router, createPinia()] },
|
global: { plugins: [router, createPinia()] },
|
||||||
})
|
})
|
||||||
expect(wrapper.find('button').exists()).toBe(false)
|
expect(wrapper.find('.app-header__logout').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show user email', () => {
|
it('does not show user email', () => {
|
||||||
|
|
@ -178,7 +178,7 @@ describe('AppHeader', () => {
|
||||||
|
|
||||||
it('shows settings menu with account links', async () => {
|
it('shows settings menu with account links', async () => {
|
||||||
const { wrapper } = mountAuthenticated()
|
const { wrapper } = mountAuthenticated()
|
||||||
expect(wrapper.text()).not.toContain('Byt lösenord')
|
expect(wrapper.findAll('.app-header__settings-item')).toHaveLength(0)
|
||||||
|
|
||||||
await wrapper.find('.app-header__settings-trigger').trigger('click')
|
await wrapper.find('.app-header__settings-trigger').trigger('click')
|
||||||
|
|
||||||
|
|
@ -190,15 +190,26 @@ describe('AppHeader', () => {
|
||||||
expect(links[1].text()).toBe('Byt lösenord')
|
expect(links[1].text()).toBe('Byt lösenord')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('toggles mobile menu open state when menu button is clicked', async () => {
|
||||||
|
const { wrapper } = mountAuthenticated()
|
||||||
|
|
||||||
|
await wrapper.find('.app-header__menu-toggle').trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.classes()).toContain('app-header--menu-open')
|
||||||
|
expect(document.body.classList.contains('nav-menu-open')).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('Byt e-postadress')
|
||||||
|
})
|
||||||
|
|
||||||
it('highlights settings trigger on change password page', async () => {
|
it('highlights settings trigger on change password page', async () => {
|
||||||
const { wrapper, router } = mountAuthenticated()
|
const { wrapper, router } = mountAuthenticated()
|
||||||
await router.push('/andra-losenord')
|
await router.push('/andra-losenord')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
expect(
|
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
|
||||||
wrapper.find('.app-header__settings-trigger').classes(),
|
'app-header__settings-trigger--active',
|
||||||
).toContain('app-header__settings-trigger--active')
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('highlights settings trigger on change email page', async () => {
|
it('highlights settings trigger on change email page', async () => {
|
||||||
|
|
@ -207,9 +218,9 @@ describe('AppHeader', () => {
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
expect(
|
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
|
||||||
wrapper.find('.app-header__settings-trigger').classes(),
|
'app-header__settings-trigger--active',
|
||||||
).toContain('app-header__settings-trigger--active')
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not highlight settings trigger on other pages', async () => {
|
it('does not highlight settings trigger on other pages', async () => {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ describe('ChangeEmailPage', () => {
|
||||||
it('renders current email and form fields', () => {
|
it('renders current email and form fields', () => {
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
setActivePinia(pinia)
|
setActivePinia(pinia)
|
||||||
localStorage.setItem('auth_token', makeJwt({ sub: 'test@bilhej.se', role: 'user' }))
|
localStorage.setItem(
|
||||||
|
'auth_token',
|
||||||
|
makeJwt({ sub: 'test@bilhej.se', role: 'user' }),
|
||||||
|
)
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
|
|
@ -35,7 +38,10 @@ describe('ChangeEmailPage', () => {
|
||||||
it('shows auth email from store', () => {
|
it('shows auth email from store', () => {
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
setActivePinia(pinia)
|
setActivePinia(pinia)
|
||||||
localStorage.setItem('auth_token', makeJwt({ sub: 'user@example.com', role: 'user' }))
|
localStorage.setItem(
|
||||||
|
'auth_token',
|
||||||
|
makeJwt({ sub: 'user@example.com', role: 'user' }),
|
||||||
|
)
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ describe('ContactPage', () => {
|
||||||
const link = wrapper.find('a[href="mailto:support@bilhej.se"]')
|
const link = wrapper.find('a[href="mailto:support@bilhej.se"]')
|
||||||
expect(link.exists()).toBe(true)
|
expect(link.exists()).toBe(true)
|
||||||
expect(link.text()).toBe('support@bilhej.se')
|
expect(link.text()).toBe('support@bilhej.se')
|
||||||
expect(link.attributes('aria-label')).toBe('Skicka till support: support@bilhej.se')
|
expect(link.attributes('aria-label')).toBe(
|
||||||
|
'Skicka till support: support@bilhej.se',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import router from '@/router'
|
import router, { scrollBehavior } from '@/router'
|
||||||
|
|
||||||
describe('Router', () => {
|
describe('Router', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -8,6 +8,25 @@ describe('Router', () => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('scrolls to top on route change without hash', () => {
|
||||||
|
const position = scrollBehavior(
|
||||||
|
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
|
||||||
|
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
expect(position).toEqual({ top: 0, left: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores saved position when using browser back', () => {
|
||||||
|
const saved = { top: 120, left: 0 }
|
||||||
|
const position = scrollBehavior(
|
||||||
|
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
|
||||||
|
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
|
||||||
|
saved,
|
||||||
|
)
|
||||||
|
expect(position).toBe(saved)
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves / to HomePage', async () => {
|
it('resolves / to HomePage', async () => {
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,10 @@ a {
|
||||||
/* transitions */
|
/* transitions */
|
||||||
--transition-fast: 150ms ease;
|
--transition-fast: 150ms ease;
|
||||||
--transition-base: 200ms ease;
|
--transition-base: 200ms ease;
|
||||||
|
|
||||||
|
/* layout */
|
||||||
|
--page-gutter: var(--space-lg);
|
||||||
|
--header-height: 3.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Body ────────────────────────────────────────────────────────────── */
|
/* ── Body ────────────────────────────────────────────────────────────── */
|
||||||
|
|
@ -407,3 +411,34 @@ a[href]:hover {
|
||||||
.text-xs {
|
.text-xs {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive (customer-facing; max 639px = phone) ─────────────────── */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
:root {
|
||||||
|
--page-gutter: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container,
|
||||||
|
.container--narrow,
|
||||||
|
.container--wide {
|
||||||
|
padding-inline: var(--page-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-card {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--block-sm {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.btn--block-sm {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { RouterLink } from 'vue-router'
|
||||||
.app-footer__inner {
|
.app-footer__inner {
|
||||||
max-width: 72rem;
|
max-width: 72rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-xl) var(--space-lg);
|
padding: var(--space-xl) var(--page-gutter);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,4 +66,15 @@ import { RouterLink } from 'vue-router'
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.app-footer__links {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer__inner {
|
||||||
|
padding-block: var(--space-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
|
@ -7,10 +7,10 @@ const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const isSettingsActive = computed(
|
const isSettingsActive = computed(
|
||||||
() =>
|
() => route.name === 'change-email' || route.name === 'change-password',
|
||||||
route.name === 'change-email' || route.name === 'change-password',
|
|
||||||
)
|
)
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
|
const menuOpen = ref(false)
|
||||||
const settingsRef = ref<HTMLElement | null>(null)
|
const settingsRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
function toggleSettings() {
|
function toggleSettings() {
|
||||||
|
|
@ -21,30 +21,67 @@ function closeSettings() {
|
||||||
settingsOpen.value = false
|
settingsOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
menuOpen.value = !menuOpen.value
|
||||||
|
if (!menuOpen.value) {
|
||||||
|
closeSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
menuOpen.value = false
|
||||||
|
closeSettings()
|
||||||
|
}
|
||||||
|
|
||||||
function handleDocumentClick(event: MouseEvent) {
|
function handleDocumentClick(event: MouseEvent) {
|
||||||
if (!settingsRef.value?.contains(event.target as Node)) {
|
if (!settingsRef.value?.contains(event.target as Node)) {
|
||||||
settingsOpen.value = false
|
settingsOpen.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNavClick() {
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
|
closeMenu()
|
||||||
auth.logout()
|
auth.logout()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
closeMenu()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(menuOpen, (open) => {
|
||||||
|
document.body.classList.toggle('nav-menu-open', open)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handleDocumentClick)
|
document.addEventListener('click', handleDocumentClick)
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleDocumentClick)
|
document.removeEventListener('click', handleDocumentClick)
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
document.body.classList.remove('nav-menu-open')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="app-header">
|
<header class="app-header" :class="{ 'app-header--menu-open': menuOpen }">
|
||||||
<div class="app-header__inner">
|
<div class="app-header__inner">
|
||||||
<RouterLink to="/" class="app-header__logo">
|
<RouterLink to="/" class="app-header__logo" @click="handleNavClick">
|
||||||
<svg
|
<svg
|
||||||
class="app-header__logo-icon"
|
class="app-header__logo-icon"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|
@ -71,13 +108,62 @@ onUnmounted(() => {
|
||||||
</svg>
|
</svg>
|
||||||
Bilhej
|
Bilhej
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<nav class="app-header__nav">
|
|
||||||
<RouterLink to="/" class="app-header__link">Hem</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">
|
<template v-if="!auth.isAuthenticated">
|
||||||
<RouterLink to="/logga-in" class="app-header__link"
|
<RouterLink
|
||||||
|
to="/logga-in"
|
||||||
|
class="app-header__link"
|
||||||
|
@click="handleNavClick"
|
||||||
>Logga in</RouterLink
|
>Logga in</RouterLink
|
||||||
>
|
>
|
||||||
<RouterLink to="/registrera" class="app-header__link"
|
<RouterLink
|
||||||
|
to="/registrera"
|
||||||
|
class="app-header__link"
|
||||||
|
@click="handleNavClick"
|
||||||
>Registrera</RouterLink
|
>Registrera</RouterLink
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -86,11 +172,37 @@ onUnmounted(() => {
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isAdmin"
|
||||||
to="/admin"
|
to="/admin"
|
||||||
class="app-header__link app-header__link--admin"
|
class="app-header__link app-header__link--admin"
|
||||||
|
@click="handleNavClick"
|
||||||
>Admin</RouterLink
|
>Admin</RouterLink
|
||||||
>
|
>
|
||||||
<RouterLink to="/orders" class="app-header__link"
|
<RouterLink
|
||||||
|
to="/orders"
|
||||||
|
class="app-header__link"
|
||||||
|
@click="handleNavClick"
|
||||||
>Mina beställningar</RouterLink
|
>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">
|
<div ref="settingsRef" class="app-header__settings">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -165,9 +277,10 @@ onUnmounted(() => {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
max-width: 72rem;
|
max-width: 72rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0.875rem var(--space-lg);
|
padding: 0.875rem var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header__logo {
|
.app-header__logo {
|
||||||
|
|
@ -178,6 +291,7 @@ onUnmounted(() => {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header__logo-icon {
|
.app-header__logo-icon {
|
||||||
|
|
@ -185,6 +299,26 @@ onUnmounted(() => {
|
||||||
height: 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 {
|
.app-header__nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -209,6 +343,11 @@ onUnmounted(() => {
|
||||||
background: var(--color-primary-soft);
|
background: var(--color-primary-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header__link--active-settings {
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.app-header__link--admin {
|
.app-header__link--admin {
|
||||||
background: var(--color-primary-soft);
|
background: var(--color-primary-soft);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
|
@ -220,10 +359,18 @@ onUnmounted(() => {
|
||||||
color: var(--color-primary-dark);
|
color: var(--color-primary-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header__link--settings-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header__email {
|
.app-header__email {
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
|
max-width: 12rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header__settings {
|
.app-header__settings {
|
||||||
|
|
@ -313,4 +460,69 @@ onUnmounted(() => {
|
||||||
border-color: var(--color-danger);
|
border-color: var(--color-danger);
|
||||||
background: var(--color-danger-soft);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ const highlights = [
|
||||||
.about {
|
.about {
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
|
padding: clamp(var(--space-xl), 6vw, var(--space-3xl)) var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.about__hero {
|
.about__hero {
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ async function handleSubmit() {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.page__card {
|
||||||
|
|
|
||||||
|
|
@ -148,8 +148,8 @@ async function handleSubmit() {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.page__card {
|
||||||
|
|
|
||||||
|
|
@ -282,8 +282,10 @@ async function handleSubmit() {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 639px) {
|
||||||
.compose__layout {
|
.compose__layout {
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
padding-inline: var(--page-gutter);
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ async function handleSubmit() {
|
||||||
} else if (err instanceof ApiError) {
|
} else if (err instanceof ApiError) {
|
||||||
errorMessage.value = err.message
|
errorMessage.value = err.message
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = 'Något gick fel. Begär en ny bekräftelselänk från inställningar.'
|
errorMessage.value =
|
||||||
|
'Något gick fel. Begär en ny bekräftelselänk från inställningar.'
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
|
|
@ -106,8 +107,8 @@ async function handleSubmit() {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.page__card {
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ const contactChannels = [
|
||||||
.contact {
|
.contact {
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
|
padding: clamp(var(--space-xl), 6vw, var(--space-3xl)) var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact__hero {
|
.contact__hero {
|
||||||
|
|
@ -197,7 +197,9 @@ const contactChannels = [
|
||||||
background: var(--color-primary-soft);
|
background: var(--color-primary-soft);
|
||||||
border: 1px solid #bfdbfe;
|
border: 1px solid #bfdbfe;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
border-color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact__mailto:hover {
|
.contact__mailto:hover {
|
||||||
|
|
|
||||||
|
|
@ -327,8 +327,10 @@ onMounted(loadOrder)
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 639px) {
|
||||||
.compose__layout {
|
.compose__layout {
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
padding-inline: var(--page-gutter);
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,8 @@ async function handleSubmit() {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.page__card {
|
||||||
|
|
|
||||||
|
|
@ -1140,11 +1140,11 @@ async function handleLookup(lookedUpPlate: string) {
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 639px) {
|
||||||
.home__hero {
|
.home__hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-xl);
|
gap: var(--space-xl);
|
||||||
padding: var(--space-xl) var(--space-lg);
|
padding: var(--space-xl) var(--page-gutter);
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
|
|
@ -1168,5 +1168,15 @@ async function handleLookup(lookedUpPlate: string) {
|
||||||
.home__use--wide .home__use-icon {
|
.home__use--wide .home__use-icon {
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home__uses,
|
||||||
|
.home__steps,
|
||||||
|
.home__trust {
|
||||||
|
padding-inline: var(--page-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__trust-inner {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,8 @@ async function handleSubmit() {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.page__card {
|
||||||
|
|
|
||||||
|
|
@ -316,8 +316,8 @@ onMounted(loadOrders)
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__title {
|
.page__title {
|
||||||
|
|
@ -604,4 +604,33 @@ onMounted(loadOrders)
|
||||||
.orders__loading {
|
.orders__loading {
|
||||||
padding: var(--space-2xl) 0;
|
padding: var(--space-2xl) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.orders__card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__plate-badge {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__links {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__link-sep {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__text-link {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -125,8 +125,8 @@ async function confirmPayment() {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.page__card {
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,8 @@ const sections = [
|
||||||
<p class="policy__eyebrow">Integritet</p>
|
<p class="policy__eyebrow">Integritet</p>
|
||||||
<h1 class="policy__title">Integritetspolicy</h1>
|
<h1 class="policy__title">Integritetspolicy</h1>
|
||||||
<p class="policy__lead">
|
<p class="policy__lead">
|
||||||
Här beskriver vi hur Bilhej behandlar personuppgifter när du skickar brev
|
Här beskriver vi hur Bilhej behandlar personuppgifter när du skickar
|
||||||
via tjänsten, och vilka rättigheter du har.
|
brev via tjänsten, och vilka rättigheter du har.
|
||||||
</p>
|
</p>
|
||||||
<p class="policy__updated">Senast uppdaterad: 22 maj 2026</p>
|
<p class="policy__updated">Senast uppdaterad: 22 maj 2026</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -113,7 +113,8 @@ const sections = [
|
||||||
>kontakt@bilhej.se</a
|
>kontakt@bilhej.se</a
|
||||||
>
|
>
|
||||||
eller vår
|
eller vår
|
||||||
<RouterLink to="/kontakt" class="policy__link">kontaktsida</RouterLink>.
|
<RouterLink to="/kontakt" class="policy__link">kontaktsida</RouterLink
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -124,7 +125,7 @@ const sections = [
|
||||||
.policy {
|
.policy {
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
|
padding: clamp(var(--space-xl), 6vw, var(--space-3xl)) var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.policy__hero {
|
.policy__hero {
|
||||||
|
|
|
||||||
|
|
@ -165,8 +165,8 @@ async function handleSubmit() {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.page__card {
|
||||||
|
|
|
||||||
|
|
@ -149,8 +149,8 @@ async function handleSubmit() {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.page__card {
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,8 @@ const sections = [
|
||||||
>support@bilhej.se</a
|
>support@bilhej.se</a
|
||||||
>
|
>
|
||||||
eller vår
|
eller vår
|
||||||
<RouterLink to="/kontakt" class="terms__link">kontaktsida</RouterLink>.
|
<RouterLink to="/kontakt" class="terms__link">kontaktsida</RouterLink
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -143,7 +144,7 @@ const sections = [
|
||||||
.terms {
|
.terms {
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
|
padding: clamp(var(--space-xl), 6vw, var(--space-3xl)) var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.terms__hero {
|
.terms__hero {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import {
|
||||||
|
createRouter,
|
||||||
|
createWebHistory,
|
||||||
|
type RouteLocationNormalized,
|
||||||
|
} from 'vue-router'
|
||||||
import HomePage from '@/pages/HomePage.vue'
|
import HomePage from '@/pages/HomePage.vue'
|
||||||
import ComposePage from '@/pages/ComposePage.vue'
|
import ComposePage from '@/pages/ComposePage.vue'
|
||||||
import AboutPage from '@/pages/AboutPage.vue'
|
import AboutPage from '@/pages/AboutPage.vue'
|
||||||
|
|
@ -19,8 +23,23 @@ import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { getActivePinia } from 'pinia'
|
import { getActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
export function scrollBehavior(
|
||||||
|
to: RouteLocationNormalized,
|
||||||
|
_from: RouteLocationNormalized,
|
||||||
|
savedPosition: { left: number; top: number } | null,
|
||||||
|
) {
|
||||||
|
if (savedPosition) {
|
||||||
|
return savedPosition
|
||||||
|
}
|
||||||
|
if (to.hash) {
|
||||||
|
return { el: to.hash, top: 0, behavior: 'smooth' as const }
|
||||||
|
}
|
||||||
|
return { top: 0, left: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
scrollBehavior,
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue