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' })
let plateCounter = 0
function uniquePlate(prefix: string): string {
const digits = String((Date.now() % 90) + 10)
return `${prefix}${digits}E`
plateCounter += 1
const digits = String(10 + (plateCounter % 90))
const letter = String.fromCharCode(65 + (plateCounter % 26))
return `${prefix}${digits}${letter}`
}
test.describe('Deferred payment and admin lookup', () => {
const plate = uniquePlate('LAT')
let plate = ''
const letterText = 'E2E-test: betalar senare från orderhistoriken.'
let orderId = ''
@ -35,9 +39,27 @@ test.describe('Deferred payment and admin lookup', () => {
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 ({
page,
}) => {
plate = uniquePlate('LAT')
await loginAsTestUser(page)
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()
})
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,
}) => {
await loginAsAdmin(page)
await page.goto('/admin')
await page.getByRole('button', { name: /Att göra/ }).click()
await page.locator('#admin-order-search').fill(shortOrderId)
await openAdminTodoBoard(page)
await searchAdminOrders(page, shortOrderId)
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__plate')).toHaveText(plate)
await expect(row).toBeVisible({ timeout: 15_000 })
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__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 ({
page,
}) => {
await loginAsAdmin(page)
await page.goto('/admin')
await searchAdminOrders(page, orderId)
await expect(
page.locator('.admin__row', { hasText: shortOrderId }),
).toBeVisible()
await page.getByRole('button', { name: /Att göra/ }).click()
await page.locator('#admin-order-search').fill(plate)
const row = page.locator('.admin__row', { hasText: shortOrderId })
await expect(row).toBeVisible()
await expect(row.locator('.admin__plate')).toHaveText(plate)
await searchAdminOrders(page, plateInAdmin!)
const rowByPlate = page.locator('.admin__row').filter({
has: page.locator('.admin__plate', { hasText: plateInAdmin! }),
})
await expect(rowByPlate).toBeVisible()
await expect(rowByPlate.locator('.admin__order-id')).toHaveText(shortOrderId)
})
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 loginAsAdmin(page)
await page.goto('/admin')
await page.getByRole('button', { name: /Att göra/ }).click()
await openAdminTodoBoard(page)
const unpaidRow = page.locator('.admin__row', { hasText: unpaidShortId })
await expect(unpaidRow).not.toBeVisible()
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.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) {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/')

View file

@ -23,6 +23,14 @@ export default defineConfig({
projects: [
{
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' },
},
],

View file

@ -105,7 +105,7 @@ describe('AppHeader', () => {
const wrapper = mount(AppHeader, {
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', () => {
@ -178,7 +178,7 @@ describe('AppHeader', () => {
it('shows settings menu with account links', async () => {
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')
@ -190,15 +190,26 @@ describe('AppHeader', () => {
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 () => {
const { wrapper, router } = mountAuthenticated()
await router.push('/andra-losenord')
await router.isReady()
await wrapper.vm.$nextTick()
expect(
wrapper.find('.app-header__settings-trigger').classes(),
).toContain('app-header__settings-trigger--active')
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
'app-header__settings-trigger--active',
)
})
it('highlights settings trigger on change email page', async () => {
@ -207,9 +218,9 @@ describe('AppHeader', () => {
await router.isReady()
await wrapper.vm.$nextTick()
expect(
wrapper.find('.app-header__settings-trigger').classes(),
).toContain('app-header__settings-trigger--active')
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
'app-header__settings-trigger--active',
)
})
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', () => {
const pinia = createPinia()
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({
history: createMemoryHistory(),
@ -35,7 +38,10 @@ describe('ChangeEmailPage', () => {
it('shows auth email from store', () => {
const pinia = createPinia()
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({
history: createMemoryHistory(),

View file

@ -29,6 +29,8 @@ describe('ContactPage', () => {
const link = wrapper.find('a[href="mailto:support@bilhej.se"]')
expect(link.exists()).toBe(true)
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 { setActivePinia, createPinia } from 'pinia'
import router from '@/router'
import router, { scrollBehavior } from '@/router'
describe('Router', () => {
beforeEach(() => {
@ -8,6 +8,25 @@ describe('Router', () => {
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 () => {
await router.push('/')
await router.isReady()

View file

@ -94,6 +94,10 @@ a {
/* transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
/* layout */
--page-gutter: var(--space-lg);
--header-height: 3.25rem;
}
/* ── Body ────────────────────────────────────────────────────────────── */
@ -407,3 +411,34 @@ a[href]:hover {
.text-xs {
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 {
max-width: 72rem;
margin: 0 auto;
padding: var(--space-xl) var(--space-lg);
padding: var(--space-xl) var(--page-gutter);
text-align: center;
}
@ -66,4 +66,15 @@ import { RouterLink } from 'vue-router'
font-size: 0.75rem;
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>

View file

@ -1,5 +1,5 @@
<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 { useAuthStore } from '@/stores/authStore'
@ -7,10 +7,10 @@ const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const isSettingsActive = computed(
() =>
route.name === 'change-email' || route.name === 'change-password',
() => route.name === 'change-email' || route.name === 'change-password',
)
const settingsOpen = ref(false)
const menuOpen = ref(false)
const settingsRef = ref<HTMLElement | null>(null)
function toggleSettings() {
@ -21,30 +21,67 @@ 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">
<header class="app-header" :class="{ 'app-header--menu-open': menuOpen }">
<div class="app-header__inner">
<RouterLink to="/" class="app-header__logo">
<RouterLink to="/" class="app-header__logo" @click="handleNavClick">
<svg
class="app-header__logo-icon"
viewBox="0 0 24 24"
@ -71,13 +108,62 @@ onUnmounted(() => {
</svg>
Bilhej
</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">
<RouterLink to="/logga-in" class="app-header__link"
<RouterLink
to="/logga-in"
class="app-header__link"
@click="handleNavClick"
>Logga in</RouterLink
>
<RouterLink to="/registrera" class="app-header__link"
<RouterLink
to="/registrera"
class="app-header__link"
@click="handleNavClick"
>Registrera</RouterLink
>
</template>
@ -86,11 +172,37 @@ onUnmounted(() => {
v-if="auth.isAdmin"
to="/admin"
class="app-header__link app-header__link--admin"
@click="handleNavClick"
>Admin</RouterLink
>
<RouterLink to="/orders" class="app-header__link"
<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"
@ -165,9 +277,10 @@ onUnmounted(() => {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
max-width: 72rem;
margin: 0 auto;
padding: 0.875rem var(--space-lg);
padding: 0.875rem var(--page-gutter);
}
.app-header__logo {
@ -178,6 +291,7 @@ onUnmounted(() => {
font-weight: 700;
color: var(--color-ink);
text-decoration: none;
flex-shrink: 0;
}
.app-header__logo-icon {
@ -185,6 +299,26 @@ onUnmounted(() => {
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;
@ -209,6 +343,11 @@ onUnmounted(() => {
background: var(--color-primary-soft);
}
.app-header__link--active-settings {
color: var(--color-primary-dark);
background: var(--color-primary-soft);
}
.app-header__link--admin {
background: var(--color-primary-soft);
color: var(--color-primary);
@ -220,10 +359,18 @@ onUnmounted(() => {
color: var(--color-primary-dark);
}
.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 {
@ -313,4 +460,69 @@ onUnmounted(() => {
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>

View file

@ -82,7 +82,7 @@ const highlights = [
.about {
max-width: 48rem;
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 {

View file

@ -124,8 +124,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -148,8 +148,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -282,8 +282,10 @@ async function handleSubmit() {
text-decoration: underline;
}
@media (max-width: 768px) {
@media (max-width: 639px) {
.compose__layout {
margin-top: var(--space-xl);
padding-inline: var(--page-gutter);
grid-template-columns: 1fr;
}

View file

@ -38,7 +38,8 @@ async function handleSubmit() {
} else if (err instanceof ApiError) {
errorMessage.value = err.message
} 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 {
submitting.value = false
@ -106,8 +107,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -86,7 +86,7 @@ const contactChannels = [
.contact {
max-width: 48rem;
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 {
@ -197,7 +197,9 @@ const contactChannels = [
background: var(--color-primary-soft);
border: 1px solid #bfdbfe;
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 {

View file

@ -327,8 +327,10 @@ onMounted(loadOrder)
text-decoration: underline;
}
@media (max-width: 768px) {
@media (max-width: 639px) {
.compose__layout {
margin-top: var(--space-xl);
padding-inline: var(--page-gutter);
grid-template-columns: 1fr;
}

View file

@ -87,8 +87,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -1140,11 +1140,11 @@ async function handleLookup(lookedUpPlate: string) {
line-height: 1.65;
}
@media (max-width: 900px) {
@media (max-width: 639px) {
.home__hero {
grid-template-columns: 1fr;
gap: var(--space-xl);
padding: var(--space-xl) var(--space-lg);
padding: var(--space-xl) var(--page-gutter);
margin-top: 0;
border-radius: 0;
border-left: none;
@ -1168,5 +1168,15 @@ async function handleLookup(lookedUpPlate: string) {
.home__use--wide .home__use-icon {
margin-bottom: var(--space-md);
}
.home__uses,
.home__steps,
.home__trust {
padding-inline: var(--page-gutter);
}
.home__trust-inner {
padding: var(--space-lg);
}
}
</style>

View file

@ -103,8 +103,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -316,8 +316,8 @@ onMounted(loadOrders)
<style scoped>
.page {
max-width: 48rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__title {
@ -604,4 +604,33 @@ onMounted(loadOrders)
.orders__loading {
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>

View file

@ -125,8 +125,8 @@ async function confirmPayment() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -84,8 +84,8 @@ const sections = [
<p class="policy__eyebrow">Integritet</p>
<h1 class="policy__title">Integritetspolicy</h1>
<p class="policy__lead">
Här beskriver vi hur Bilhej behandlar personuppgifter när du skickar brev
via tjänsten, och vilka rättigheter du har.
Här beskriver vi hur Bilhej behandlar personuppgifter när du skickar
brev via tjänsten, och vilka rättigheter du har.
</p>
<p class="policy__updated">Senast uppdaterad: 22 maj 2026</p>
</section>
@ -113,7 +113,8 @@ const sections = [
>kontakt@bilhej.se</a
>
eller vår
<RouterLink to="/kontakt" class="policy__link">kontaktsida</RouterLink>.
<RouterLink to="/kontakt" class="policy__link">kontaktsida</RouterLink
>.
</p>
</div>
</section>
@ -124,7 +125,7 @@ const sections = [
.policy {
max-width: 48rem;
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 {

View file

@ -165,8 +165,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -149,8 +149,8 @@ async function handleSubmit() {
<style scoped>
.page {
max-width: 28rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {

View file

@ -132,7 +132,8 @@ const sections = [
>support@bilhej.se</a
>
eller vår
<RouterLink to="/kontakt" class="terms__link">kontaktsida</RouterLink>.
<RouterLink to="/kontakt" class="terms__link">kontaktsida</RouterLink
>.
</p>
</div>
</section>
@ -143,7 +144,7 @@ const sections = [
.terms {
max-width: 48rem;
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 {

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 ComposePage from '@/pages/ComposePage.vue'
import AboutPage from '@/pages/AboutPage.vue'
@ -19,8 +23,23 @@ import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import { useAuthStore } from '@/stores/authStore'
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({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior,
routes: [
{
path: '/',