Merge pull request 'Make customer-facing UI usable on smartphones.' (#6) from feature/mobile-responsive into master
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/6
This commit is contained in:
commit
17fe67ae3f
27 changed files with 511 additions and 96 deletions
|
|
@ -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()
|
||||
|
||||
await searchAdminOrders(page, orderId)
|
||||
await expect(
|
||||
page.locator('.admin__row', { hasText: shortOrderId }),
|
||||
).toBeVisible()
|
||||
|
||||
await searchAdminOrders(page, plateInAdmin!)
|
||||
const rowByPlate = page.locator('.admin__row').filter({
|
||||
has: page.locator('.admin__plate', { hasText: plateInAdmin! }),
|
||||
})
|
||||
|
||||
test('admin finds paid order when searching registration number', 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(plate)
|
||||
|
||||
const row = page.locator('.admin__row', { hasText: shortOrderId })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row.locator('.admin__plate')).toHaveText(plate)
|
||||
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!)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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('/')
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: '/',
|
||||
|
|
|
|||
Loading…
Reference in a new issue