Merge pull request 'Make customer-facing UI usable on smartphones.' (#6) from feature/mobile-responsive into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m11s
CI / E2E browser tests (push) Successful in 56s

Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/6
This commit is contained in:
jocke 2026-05-26 11:48:49 +00:00
commit 17fe67ae3f
27 changed files with 511 additions and 96 deletions

View file

@ -2,13 +2,17 @@ import { test, expect } from '@playwright/test'
test.describe.configure({ mode: 'serial' }) test.describe.configure({ mode: 'serial' })
let plateCounter = 0
function uniquePlate(prefix: string): string { function uniquePlate(prefix: string): string {
const digits = String((Date.now() % 90) + 10) plateCounter += 1
return `${prefix}${digits}E` const digits = String(10 + (plateCounter % 90))
const letter = String.fromCharCode(65 + (plateCounter % 26))
return `${prefix}${digits}${letter}`
} }
test.describe('Deferred payment and admin lookup', () => { test.describe('Deferred payment and admin lookup', () => {
const plate = uniquePlate('LAT') let plate = ''
const letterText = 'E2E-test: betalar senare från orderhistoriken.' const letterText = 'E2E-test: betalar senare från orderhistoriken.'
let orderId = '' let orderId = ''
@ -35,9 +39,27 @@ test.describe('Deferred payment and admin lookup', () => {
await page.getByRole('button', { name: 'Ja, jag har betalat' }).click() await page.getByRole('button', { name: 'Ja, jag har betalat' }).click()
} }
async function openAdminTodoBoard(page: import('@playwright/test').Page) {
await page.goto('/admin')
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
await page.getByRole('button', { name: /Att göra/ }).click()
await expect(page.locator('.admin__stat--active')).toContainText('Att göra')
}
async function searchAdminOrders(
page: import('@playwright/test').Page,
query: string,
) {
const search = page.locator('#admin-order-search')
await search.click()
await search.fill(query)
await expect(search).toHaveValue(query)
}
test('user creates order, leaves payment, and pays later from orders', async ({ test('user creates order, leaves payment, and pays later from orders', async ({
page, page,
}) => { }) => {
plate = uniquePlate('LAT')
await loginAsTestUser(page) await loginAsTestUser(page)
await page.goto(`/compose?plate=${plate}`) await page.goto(`/compose?plate=${plate}`)
@ -70,47 +92,31 @@ test.describe('Deferred payment and admin lookup', () => {
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).not.toBeVisible() await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).not.toBeVisible()
}) })
test('admin finds paid order under Att göra when searching partial order id', async ({ test('admin finds paid order under Att göra by order id and plate', async ({
page, page,
}) => { }) => {
await loginAsAdmin(page) await loginAsAdmin(page)
await page.goto('/admin') await openAdminTodoBoard(page)
await page.getByRole('button', { name: /Att göra/ }).click()
await page.locator('#admin-order-search').fill(shortOrderId)
await searchAdminOrders(page, shortOrderId)
const row = page.locator('.admin__row', { hasText: shortOrderId }) const row = page.locator('.admin__row', { hasText: shortOrderId })
await expect(row).toBeVisible() await expect(row).toBeVisible({ timeout: 15_000 })
await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId)
await expect(row.locator('.admin__plate')).toHaveText(plate)
await expect(row).toHaveClass(/admin__row--todo/) await expect(row).toHaveClass(/admin__row--todo/)
})
test('admin finds paid order when searching full order id', async ({ page }) => {
await loginAsAdmin(page)
await page.goto('/admin')
await page.getByRole('button', { name: /Att göra/ }).click()
await page.locator('#admin-order-search').fill(orderId)
const row = page.locator('.admin__row', { hasText: shortOrderId })
await expect(row).toBeVisible()
await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId) await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId)
await expect(row.locator('.admin__plate')).toHaveText(plate) const plateInAdmin = (await row.locator('.admin__plate').textContent())?.trim()
}) expect(plateInAdmin).toBeTruthy()
test('admin finds paid order when searching registration number', async ({ await searchAdminOrders(page, orderId)
page, await expect(
}) => { page.locator('.admin__row', { hasText: shortOrderId }),
await loginAsAdmin(page) ).toBeVisible()
await page.goto('/admin')
await page.getByRole('button', { name: /Att göra/ }).click() await searchAdminOrders(page, plateInAdmin!)
await page.locator('#admin-order-search').fill(plate) const rowByPlate = page.locator('.admin__row').filter({
has: page.locator('.admin__plate', { hasText: plateInAdmin! }),
const row = page.locator('.admin__row', { hasText: shortOrderId }) })
await expect(row).toBeVisible() await expect(rowByPlate).toBeVisible()
await expect(row.locator('.admin__plate')).toHaveText(plate) await expect(rowByPlate.locator('.admin__order-id')).toHaveText(shortOrderId)
}) })
test('admin does not show unpaid order under Att göra before payment', async ({ test('admin does not show unpaid order under Att göra before payment', async ({
@ -130,15 +136,19 @@ test.describe('Deferred payment and admin lookup', () => {
await page.evaluate(() => localStorage.clear()) await page.evaluate(() => localStorage.clear())
await loginAsAdmin(page) await loginAsAdmin(page)
await page.goto('/admin') await openAdminTodoBoard(page)
await page.getByRole('button', { name: /Att göra/ }).click()
const unpaidRow = page.locator('.admin__row', { hasText: unpaidShortId }) const unpaidRow = page.locator('.admin__row', { hasText: unpaidShortId })
await expect(unpaidRow).not.toBeVisible() await expect(unpaidRow).not.toBeVisible()
await page.getByRole('button', { name: /Väntar/ }).click() await page.getByRole('button', { name: /Väntar/ }).click()
await page.locator('#admin-order-search').fill(unpaidPlate) await expect(page.locator('.admin__stat--active')).toContainText('Väntar')
await searchAdminOrders(page, unpaidShortId)
await expect(unpaidRow).toBeVisible({ timeout: 15_000 })
const plateInAdmin = (await unpaidRow.locator('.admin__plate').textContent())?.trim()
expect(plateInAdmin).toBeTruthy()
await searchAdminOrders(page, plateInAdmin!)
await expect(unpaidRow).toBeVisible() await expect(unpaidRow).toBeVisible()
await expect(unpaidRow.locator('.admin__plate')).toHaveText(unpaidPlate) await expect(unpaidRow.locator('.admin__plate')).toHaveText(plateInAdmin!)
}) })
}) })

View file

@ -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('/')

View file

@ -23,6 +23,14 @@ export default defineConfig({
projects: [ projects: [
{ {
name: 'chromium', name: 'chromium',
testIgnore: '**/deferred-payment-admin.spec.ts',
use: { browserName: 'chromium' },
},
{
name: 'chromium-serial',
testMatch: '**/deferred-payment-admin.spec.ts',
fullyParallel: false,
workers: 1,
use: { browserName: 'chromium' }, use: { browserName: 'chromium' },
}, },
], ],

View file

@ -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 () => {

View file

@ -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(),

View file

@ -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',
)
}) })
}) })

View file

@ -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()

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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;
} }

View file

@ -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 {

View file

@ -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 {

View file

@ -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;
} }

View file

@ -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 {

View file

@ -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>

View file

@ -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 {

View file

@ -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>

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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: '/',