Compare commits

..

3 commits

Author SHA1 Message Date
2506a0283c test: update Vitest and E2E specs for redesigned UI
- Update HomePage specs: new headline, CTA class from btn--success to btn--primary
- Update ComposePage specs: new button text, brand name in GDPR footer
- Update PaymentRedirect specs: button text, class, and test payment note
- Update TemplatePicker specs: remove emoji icon assertion
- Update AdminDashboard specs: expand button selectors instead of row clicks
- Update AppHeader specs: BilHälsning to Bilhej brand text
- Update AboutPage specs: BilHälsning to Bilhej heading
- Update App specs: new homepage headline text
- Update OrdersPage specs: badge class renames
- Update LoginPage specs: form name/action attribute tests
- Update E2E compose specs: button text, GDPR footer brand name
- Update E2E payment specs: button text and note selectors
- Update E2E admin-dashboard specs: expand button and tracking label selectors
- Update E2E header-auth specs: new test additions for admin visibility
2026-05-16 16:11:58 +02:00
851cd8afa0 refactor: redesign all pages and components with new design system
- Rewrite homepage: practical headline, use-case cards, calm trust note
- Switch from purple to blue brand tokens across all pages
- Replace all CTA buttons with brand-primary, reserve green for success
- Remove emoji from template picker and compose page
- Replace unicode chevrons with SVG expand buttons in admin
- Redesign template picker modal with accessibility semantics
- Add aria-invalid, aria-describedby to form validation
- Add role=status/alert to loading, error, and result messages
- Remove inline styles, replace with scoped utility classes
- Update compose submit text, payment button, order empty state copy
- Remove icon field from letter templates
2026-05-16 16:11:01 +02:00
00327674ed refactor: add design system with CSS tokens, utilities, and app shell
- Add design tokens (colors, spacing, radius, shadows, typography, transitions)
- Add global reset, body/link/focus/typography base styles
- Add utility classes (container, surface-card, btn variants, field, badge, message, divider)
- Replace header ✉ symbol with inline SVG envelope icon
- Update favicon to license-plate shaped mark with blue gradient and bold B
- Rename brand from BilHälsning to Bilhej in header, footer, and HTML title
- Rewrite footer tagline: focus on service, not privacy
- Add theme-color meta tag for browser chrome
2026-05-16 16:09:35 +02:00
35 changed files with 2136 additions and 1202 deletions

View file

@ -42,35 +42,35 @@ test.describe('Admin dashboard', () => {
test('shows seeded order data', async ({ page }) => { test('shows seeded order data', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.locator('.admin-dashboard__plate').first()).toBeVisible() await expect(page.locator('.admin__plate').first()).toBeVisible()
await expect(page.getByText('DEF456').first()).toBeVisible() await expect(page.getByText('DEF456').first()).toBeVisible()
await expect(page.getByText('GHI789').first()).toBeVisible() await expect(page.getByText('GHI789').first()).toBeVisible()
}) })
test('click row expands letter content', async ({ page }) => { test('click expand button shows letter content', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
const rows = page.locator('.admin-dashboard__row') const expandBtns = page.locator('.admin__expand-btn')
await rows.first().click() await expandBtns.first().click()
await expect(page.getByText('Brevtext')).toBeVisible() await expect(page.getByText('Brevtext')).toBeVisible()
}) })
test('click expanded row collapses it', async ({ page }) => { test('click expand button again collapses it', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
const rows = page.locator('.admin-dashboard__row') const expandBtns = page.locator('.admin__expand-btn')
await rows.first().click() await expandBtns.first().click()
await expect(page.getByText('Brevtext')).toBeVisible() await expect(page.getByText('Brevtext')).toBeVisible()
await rows.first().click() await expandBtns.first().click()
await expect(page.getByText('Brevtext')).not.toBeVisible() await expect(page.getByText('Brevtext')).not.toBeVisible()
}) })
test('status dropdown changes update order status', async ({ page }) => { test('status dropdown changes update order status', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
const selects = page.locator('.admin-dashboard__status-select') const selects = page.locator('.admin__status-select')
await selects.first().selectOption('delivered') await selects.first().selectOption('delivered')
const updatedSelect = selects.first() const updatedSelect = selects.first()
@ -87,21 +87,21 @@ test.describe('Admin dashboard', () => {
test('expanded row shows tracking input and save button', async ({ page }) => { test('expanded row shows tracking input and save button', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
const rows = page.locator('.admin-dashboard__row') const expandBtns = page.locator('.admin__expand-btn')
await rows.first().click() await expandBtns.first().click()
await expect(page.getByText('Spårnings-ID')).toBeVisible() await expect(page.getByText('Spårnings-ID').first()).toBeVisible()
await expect(page.locator('.admin-dashboard__tracking-input')).toBeVisible() await expect(page.locator('.admin__tracking-input')).toBeVisible()
await expect(page.getByRole('button', { name: 'Spara spårning' })).toBeVisible() await expect(page.getByRole('button', { name: 'Spara' })).toBeVisible()
}) })
test('shows PostNord link when trackingId exists', async ({ page }) => { test('shows PostNord link when trackingId exists', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
const rows = page.locator('.admin-dashboard__row') const expandBtns = page.locator('.admin__expand-btn')
await rows.last().click() await expandBtns.last().click()
const trackingLink = page.locator('.admin-dashboard__tracking-link') const trackingLink = page.locator('.admin__tracking-link')
await expect(trackingLink).toBeVisible() await expect(trackingLink).toBeVisible()
await expect(trackingLink).toHaveAttribute('href', /postnord/) await expect(trackingLink).toHaveAttribute('href', /postnord/)
}) })
@ -109,10 +109,11 @@ test.describe('Admin dashboard', () => {
test('hides PostNord link when trackingId is null', async ({ page }) => { test('hides PostNord link when trackingId is null', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
const defRow = page.locator('.admin-dashboard__row', { hasText: 'DEF456' }).first() const defRow = page.locator('.admin__row', { hasText: 'DEF456' }).first()
await defRow.click() const expandBtn = defRow.locator('.admin__expand-btn')
await expandBtn.click()
const trackingLink = page.locator('.admin-dashboard__tracking-link') const trackingLink = page.locator('.admin__tracking-link')
await expect(trackingLink).not.toBeVisible() await expect(trackingLink).not.toBeVisible()
}) })
}) })

View file

@ -44,7 +44,7 @@ test.describe('Compose flow', () => {
await page.goto('/compose?plate=ABC123') await page.goto('/compose?plate=ABC123')
const button = page.getByRole('button', { name: 'Skicka brev (49 kr)' }) const button = page.getByRole('button', { name: 'Fortsätt till betalning' })
await expect(button).toBeDisabled() await expect(button).toBeDisabled()
}) })
@ -58,7 +58,7 @@ test.describe('Compose flow', () => {
await page.goto('/compose?plate=ABC123') await page.goto('/compose?plate=ABC123')
await page.getByLabel('Ditt meddelande').fill('Hej fin bil!') await page.getByLabel('Ditt meddelande').fill('Hej fin bil!')
const button = page.getByRole('button', { name: 'Skicka brev (49 kr)' }) const button = page.getByRole('button', { name: 'Fortsätt till betalning' })
await expect(button).toBeEnabled() await expect(button).toBeEnabled()
await button.click() await button.click()
@ -79,7 +79,7 @@ test.describe('Compose flow', () => {
await page.getByLabel('Ditt meddelande').fill('Testmeddelande') await page.getByLabel('Ditt meddelande').fill('Testmeddelande')
await expect( await expect(
page.getByText('Detta brev skickades via BilHej.se'), page.getByText('Detta brev skickades via Bilhej'),
).toBeVisible() ).toBeVisible()
await expect(page.getByText('Transportstyrelsens fordonsregister')).toBeVisible() await expect(page.getByText('Transportstyrelsens fordonsregister')).toBeVisible()
}) })

View file

@ -98,6 +98,51 @@ test.describe('Header auth state', () => {
).not.toBeVisible() ).not.toBeVisible()
await expect(header.getByText('test@bilhalsning.se')).not.toBeVisible() await expect(header.getByText('test@bilhalsning.se')).not.toBeVisible()
}) })
test('logout redirects to home page', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/orders')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/orders')
await page.waitForURL('/orders')
await page.locator('header').getByRole('button', { name: 'Logga ut' }).click()
await expect(page).toHaveURL('/')
})
test('shows admin link when admin is authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'admin@bilhalsning.se', role: 'admin' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
const adminLink = header.getByRole('link', { name: 'Admin' })
await expect(adminLink).toBeVisible()
await expect(adminLink).toHaveAttribute('href', '/admin')
})
test('does not show admin link for regular user', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
await expect(
header.getByRole('link', { name: 'Admin' }),
).not.toBeVisible()
})
}) })
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {

View file

@ -45,4 +45,21 @@ test.describe('Login page', () => {
'password', 'password',
) )
}) })
test('login form has name attributes and form action', async ({ page }) => {
await page.goto('/logga-in')
const form = page.locator('form')
await expect(form).toHaveAttribute('method', 'post')
await expect(form).toHaveAttribute('action', '/api/auth/login')
await expect(page.getByLabel('E-postadress')).toHaveAttribute(
'name',
'email',
)
await expect(page.getByLabel('Lösenord')).toHaveAttribute(
'name',
'password',
)
})
}) })

View file

@ -12,7 +12,7 @@ test.describe('Payment redirect', () => {
test('can navigate to payment page from compose', async ({ page }) => { test('can navigate to payment page from compose', async ({ page }) => {
await page.goto('/compose?plate=ABC123') await page.goto('/compose?plate=ABC123')
await page.getByLabel('Ditt meddelande').fill('Hej fin bil!') await page.getByLabel('Ditt meddelande').fill('Hej fin bil!')
await page.getByRole('button', { name: 'Skicka brev (49 kr)' }).click() await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await expect(page).toHaveURL(/\/betalning\//) await expect(page).toHaveURL(/\/betalning\//)
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible()
@ -20,15 +20,15 @@ test.describe('Payment redirect', () => {
await expect(page.getByText('ABC123')).toBeVisible() await expect(page.getByText('ABC123')).toBeVisible()
}) })
test('Betalt button marks order as paid and redirects to orders', async ({ test('payment button marks order as paid and redirects to orders', async ({
page, page,
}) => { }) => {
await page.goto('/compose?plate=DEF456') await page.goto('/compose?plate=DEF456')
await page.getByLabel('Ditt meddelande').fill('Vill köpa din bil.') await page.getByLabel('Ditt meddelande').fill('Vill köpa din bil.')
await page.getByRole('button', { name: 'Skicka brev (49 kr)' }).click() await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//) await page.waitForURL(/\/betalning\//)
await page.getByRole('button', { name: 'Betalt' }).click() await page.getByRole('button', { name: 'Genomför testbetalning' }).click()
await expect(page).toHaveURL('/orders') await expect(page).toHaveURL('/orders')
await expect(page.getByText('DEF456').first()).toBeVisible() await expect(page.getByText('DEF456').first()).toBeVisible()
@ -44,9 +44,9 @@ test.describe('Payment redirect', () => {
test('shows mock payment note', async ({ page }) => { test('shows mock payment note', async ({ page }) => {
await page.goto('/compose?plate=GHI789') await page.goto('/compose?plate=GHI789')
await page.getByLabel('Ditt meddelande').fill('Hej!') await page.getByLabel('Ditt meddelande').fill('Hej!')
await page.getByRole('button', { name: 'Skicka brev (49 kr)' }).click() await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//) await page.waitForURL(/\/betalning\//)
await expect(page.getByText(/mock-betalning/i)).toBeVisible() await expect(page.locator('.payment__note')).toBeVisible()
}) })
}) })

View file

@ -2,9 +2,14 @@
<html lang="sv"> <html lang="sv">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BilHälsning</title> <meta name="description" content="Skicka ett brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, så postar vi det." />
<meta name="theme-color" content="#1d4ed8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Bilhej — Skicka brev till fordonsägare</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 990 B

View file

@ -14,6 +14,6 @@ import AppFooter from '@/components/AppFooter.vue'
<style> <style>
.app__main { .app__main {
min-height: calc(100vh - 12rem); min-height: calc(100vh - 10rem);
} }
</style> </style>

View file

@ -5,6 +5,6 @@ import AboutPage from '@/pages/AboutPage.vue'
describe('AboutPage', () => { describe('AboutPage', () => {
it('renders heading', () => { it('renders heading', () => {
const wrapper = mount(AboutPage) const wrapper = mount(AboutPage)
expect(wrapper.text()).toContain('Om BilHälsning') expect(wrapper.text()).toContain('Om Bilhej')
}) })
}) })

View file

@ -66,13 +66,10 @@ describe('AdminDashboard', () => {
) )
}) })
it('renders heading and subtitle', async () => { it('renders heading', async () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Administration') expect(wrapper.text()).toContain('Administration')
expect(wrapper.text()).toContain(
'Hantera beställningar, mallar och användare',
)
}) })
it('shows loading state initially', async () => { it('shows loading state initially', async () => {
@ -124,30 +121,31 @@ describe('AdminDashboard', () => {
expect(wrapper.text()).toContain('Kunde inte hämta beställningar') expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
}) })
it('expands row on click to show letter content', async () => { it('expands row on button click to show letter content', async () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row') const rows = wrapper.findAll('.admin__row')
expect(rows.length).toBe(2) expect(rows.length).toBe(2)
await rows[0].trigger('click') const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hej fin bil!') expect(wrapper.text()).toContain('Hej fin bil!')
expect(wrapper.text()).toContain('Brevtext') expect(wrapper.text()).toContain('Brevtext')
}) })
it('collapses row on second click', async () => { it('collapses row on second button click', async () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await rows[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hej fin bil!') expect(wrapper.text()).toContain('Hej fin bil!')
await rows[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Hej fin bil!') expect(wrapper.text()).not.toContain('Hej fin bil!')
}) })
@ -156,12 +154,12 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await rows[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hej fin bil!') expect(wrapper.text()).toContain('Hej fin bil!')
await rows[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Hej fin bil!') expect(wrapper.text()).not.toContain('Hej fin bil!')
expect(wrapper.text()).toContain('Vill köpa din bil.') expect(wrapper.text()).toContain('Vill köpa din bil.')
@ -171,7 +169,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin-dashboard__status-select') const selects = wrapper.findAll('.admin__status-select')
expect(selects.length).toBe(2) expect(selects.length).toBe(2)
}) })
@ -185,7 +183,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin-dashboard__status-select') const selects = wrapper.findAll('.admin__status-select')
await selects[0].trigger('change') await selects[0].trigger('change')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -208,7 +206,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin-dashboard__status-select') const selects = wrapper.findAll('.admin__status-select')
await selects[0].trigger('change') await selects[0].trigger('change')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -226,24 +224,24 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await rows[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.find('.admin-dashboard__tracking').exists()).toBe(true) expect(wrapper.find('.admin__tracking-row').exists()).toBe(true)
expect(wrapper.find('.admin-dashboard__tracking-input').exists()).toBe(true) expect(wrapper.find('.admin__tracking-input').exists()).toBe(true)
expect(wrapper.find('.admin-dashboard__tracking-save').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
it('shows tracking link when trackingId is set', async () => { it('shows tracking link when trackingId is set', async () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await rows[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('.admin-dashboard__tracking-link') const link = wrapper.find('.admin__tracking-link')
expect(link.exists()).toBe(true) expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('postnord') expect(link.attributes('href')).toContain('postnord')
expect(link.attributes('target')).toBe('_blank') expect(link.attributes('target')).toBe('_blank')
@ -253,11 +251,11 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await rows[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('.admin-dashboard__tracking-link') const link = wrapper.find('.admin__tracking-link')
expect(link.exists()).toBe(false) expect(link.exists()).toBe(false)
}) })
@ -269,11 +267,11 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await rows[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.admin-dashboard__tracking-save').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
@ -294,11 +292,11 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row') const expandBtns = wrapper.findAll('.admin__expand-btn')
await rows[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.admin-dashboard__tracking-save').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID') expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID')

View file

@ -29,6 +29,6 @@ describe('App', () => {
plugins: [router, createPinia()], plugins: [router, createPinia()],
}, },
}) })
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare') expect(wrapper.text()).toContain('Skicka ett brev')
}) })
}) })

View file

@ -25,6 +25,11 @@ function createTestRouter() {
name: 'orders', name: 'orders',
component: { template: '<div>Orders</div>' }, component: { template: '<div>Orders</div>' },
}, },
{
path: '/admin',
name: 'admin',
component: { template: '<div>Admin</div>' },
},
], ],
}) })
} }
@ -47,7 +52,7 @@ describe('AppHeader', () => {
const wrapper = mount(AppHeader, { const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] }, global: { plugins: [router, createPinia()] },
}) })
expect(wrapper.text()).toContain('BilHälsning') expect(wrapper.text()).toContain('Bilhej')
}) })
it('has a link to home', () => { it('has a link to home', () => {
@ -113,38 +118,39 @@ describe('AppHeader', () => {
}) })
describe('when authenticated', () => { describe('when authenticated', () => {
function mountAuthenticated() { function mountAuthenticated(role = 'user') {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' }) const jwt = makeJwt({ sub: 'test@bilhalsning.se', role })
localStorage.setItem('auth_token', jwt) localStorage.setItem('auth_token', jwt)
const pinia = createPinia() const pinia = createPinia()
setActivePinia(pinia) setActivePinia(pinia)
const router = createTestRouter() const router = createTestRouter()
return mount(AppHeader, { const wrapper = mount(AppHeader, {
global: { plugins: [router, pinia] }, global: { plugins: [router, pinia] },
}) })
return { wrapper, router }
} }
it('shows user email', () => { it('shows user email', () => {
const wrapper = mountAuthenticated() const { wrapper } = mountAuthenticated()
expect(wrapper.text()).toContain('test@bilhalsning.se') expect(wrapper.text()).toContain('test@bilhalsning.se')
}) })
it('shows logout button', () => { it('shows logout button', () => {
const wrapper = mountAuthenticated() const { wrapper } = mountAuthenticated()
const logoutButton = wrapper.find('button') const logoutButton = wrapper.find('button')
expect(logoutButton.exists()).toBe(true) expect(logoutButton.exists()).toBe(true)
expect(logoutButton.text()).toBe('Logga ut') expect(logoutButton.text()).toBe('Logga ut')
}) })
it('does not show login link', () => { it('does not show login link', () => {
const wrapper = mountAuthenticated() const { wrapper } = mountAuthenticated()
const links = wrapper.findAll('a') const links = wrapper.findAll('a')
const loginLink = links.find((a) => a.attributes('href') === '/logga-in') const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
expect(loginLink).toBeUndefined() expect(loginLink).toBeUndefined()
}) })
it('does not show register link', () => { it('does not show register link', () => {
const wrapper = mountAuthenticated() const { wrapper } = mountAuthenticated()
const links = wrapper.findAll('a') const links = wrapper.findAll('a')
const registerLink = links.find( const registerLink = links.find(
(a) => a.attributes('href') === '/registrera', (a) => a.attributes('href') === '/registrera',
@ -153,21 +159,47 @@ describe('AppHeader', () => {
}) })
it('shows orders link', () => { it('shows orders link', () => {
const wrapper = mountAuthenticated() const { wrapper } = mountAuthenticated()
const links = wrapper.findAll('a') const links = wrapper.findAll('a')
const ordersLink = links.find((a) => a.attributes('href') === '/orders') const ordersLink = links.find((a) => a.attributes('href') === '/orders')
expect(ordersLink).toBeTruthy() expect(ordersLink).toBeTruthy()
expect(ordersLink?.text()).toBe('Mina beställningar') expect(ordersLink?.text()).toBe('Mina beställningar')
}) })
it('calls logout when clicking logout button', async () => { it('does not show admin link for regular user', () => {
const wrapper = mountAuthenticated() const { wrapper } = mountAuthenticated('user')
const links = wrapper.findAll('a')
const adminLink = links.find((a) => a.attributes('href') === '/admin')
expect(adminLink).toBeUndefined()
})
it('shows admin link for admin user', () => {
const { wrapper } = mountAuthenticated('admin')
const links = wrapper.findAll('a')
const adminLink = links.find((a) => a.attributes('href') === '/admin')
expect(adminLink).toBeTruthy()
expect(adminLink?.text()).toBe('Admin')
})
it('calls logout and redirects to home when clicking logout button', async () => {
const { wrapper, router } = mountAuthenticated()
const auth = useAuthStore() const auth = useAuthStore()
expect(auth.isAuthenticated).toBe(true) expect(auth.isAuthenticated).toBe(true)
await router.push('/orders')
await router.isReady()
const navigationDone = new Promise<void>((resolve) => {
const remove = router.afterEach(() => {
remove()
resolve()
})
})
await wrapper.find('button').trigger('click') await wrapper.find('button').trigger('click')
await navigationDone
expect(auth.isAuthenticated).toBe(false) expect(auth.isAuthenticated).toBe(false)
expect(router.currentRoute.value.path).toBe('/')
}) })
}) })
}) })

View file

@ -90,7 +90,7 @@ describe('ComposePage', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea') const textarea = wrapper.find('textarea')
await textarea.setValue('a'.repeat(901)) await textarea.setValue('a'.repeat(901))
const counter = wrapper.find('.compose__counter') const counter = wrapper.find('.field__hint')
expect(counter.classes()).toContain('compose__counter--warn') expect(counter.classes()).toContain('compose__counter--warn')
}) })
@ -174,7 +174,7 @@ describe('ComposePage', () => {
it('shows GDPR footer in preview', async () => { it('shows GDPR footer in preview', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Detta brev skickades via BilHej.se') expect(wrapper.text()).toContain('Detta brev skickades via Bilhej')
}) })
it('shows Visa mallar button', async () => { it('shows Visa mallar button', async () => {

View file

@ -21,16 +21,16 @@ function mountHome(router: ReturnType<typeof createTestRouter>) {
} }
describe('HomePage', () => { describe('HomePage', () => {
it('renders subtitle', () => { it('renders headline', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mountHome(router) const wrapper = mountHome(router)
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare') expect(wrapper.text()).toContain('Skicka ett brev')
}) })
it('does not show CTA button initially', () => { it('does not show CTA button initially', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mountHome(router) const wrapper = mountHome(router)
expect(wrapper.find('.home__cta').exists()).toBe(false) expect(wrapper.find('.btn--primary').exists()).toBe(false)
}) })
it('does not show CTA while loading', async () => { it('does not show CTA while loading', async () => {
@ -41,7 +41,7 @@ describe('HomePage', () => {
await plateInput.vm.$emit('lookup', 'ABC123') await plateInput.vm.$emit('lookup', 'ABC123')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('.home__cta').exists()).toBe(false) expect(wrapper.find('.btn--primary').exists()).toBe(false)
}) })
it('does not show CTA after not-found', async () => { it('does not show CTA after not-found', async () => {
@ -52,7 +52,7 @@ describe('HomePage', () => {
await plateInput.vm.$emit('lookup', 'UNKNOWN') await plateInput.vm.$emit('lookup', 'UNKNOWN')
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
expect(wrapper.find('.home__cta').exists()).toBe(false) expect(wrapper.find('.btn--primary').exists()).toBe(false)
}) })
it('shows CTA button when vehicle data present', async () => { it('shows CTA button when vehicle data present', async () => {
@ -64,9 +64,9 @@ describe('HomePage', () => {
await plateInput.vm.$emit('lookup', 'ABC123') await plateInput.vm.$emit('lookup', 'ABC123')
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
const cta = wrapper.find('.home__cta') const cta = wrapper.find('.btn--primary')
expect(cta.exists()).toBe(true) expect(cta.exists()).toBe(true)
expect(cta.text()).toBe('Skicka ett brev till ägaren') expect(cta.text()).toBe('Fortsätt till brevet')
}) })
it('CTA links to compose page with plate query param', async () => { it('CTA links to compose page with plate query param', async () => {
@ -78,7 +78,7 @@ describe('HomePage', () => {
await plateInput.vm.$emit('lookup', 'ABC123') await plateInput.vm.$emit('lookup', 'ABC123')
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
const cta = wrapper.find('.home__cta') const cta = wrapper.find('.btn--primary')
const href = cta.attributes('href') const href = cta.attributes('href')
expect(href).toBe('/compose?plate=ABC123') expect(href).toBe('/compose?plate=ABC123')
}) })

View file

@ -70,6 +70,23 @@ describe('LoginPage', () => {
expect(wrapper.find('#confirm-password').exists()).toBe(false) expect(wrapper.find('#confirm-password').exists()).toBe(false)
}) })
it('form element has method post and action', async () => {
const { wrapper } = mountPage()
const form = wrapper.find('form')
expect(form.attributes('method')).toBe('post')
expect(form.attributes('action')).toBe('/api/auth/login')
})
it('email input has name attribute', async () => {
const { wrapper } = mountPage()
expect(wrapper.find('#email').attributes('name')).toBe('email')
})
it('password input has name attribute', async () => {
const { wrapper } = mountPage()
expect(wrapper.find('#password').attributes('name')).toBe('password')
})
it('disables submit when fields are empty', async () => { it('disables submit when fields are empty', async () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
const button = wrapper.find('button[type="submit"]') const button = wrapper.find('button[type="submit"]')

View file

@ -136,7 +136,7 @@ describe('OrdersPage', () => {
vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, [])) vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Du har inga beställningar ännu') expect(wrapper.text()).toContain('Inga beställningar ännu')
}) })
it('shows error state on API failure', async () => { it('shows error state on API failure', async () => {
@ -151,8 +151,8 @@ describe('OrdersPage', () => {
it('applies correct badge class for status', async () => { it('applies correct badge class for status', async () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const badges = wrapper.findAll('.orders__badge') const badges = wrapper.findAll('.badge')
expect(badges[0].classes()).toContain('badge--green') expect(badges[0].classes()).toContain('badge--success')
expect(badges[1].classes()).toContain('badge--gray') expect(badges[1].classes()).toContain('badge--muted')
}) })
}) })

View file

@ -66,16 +66,16 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('ABC123') expect(wrapper.text()).toContain('ABC123')
}) })
it('shows Betalt button', async () => { it('shows payment button', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const button = wrapper.find('.payment__button') const button = wrapper.find('.btn--primary')
expect(button.exists()).toBe(true) expect(button.exists()).toBe(true)
expect(button.text()).toBe('Betalt') expect(button.text()).toBe('Genomför testbetalning')
}) })
it('shows mock payment note', async () => { it('shows test payment note', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('mock-betalning') expect(wrapper.text()).toContain('testbetalning')
}) })
it('calls payOrder on button click', async () => { it('calls payOrder on button click', async () => {
@ -89,7 +89,7 @@ describe('PaymentRedirect', () => {
}) })
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await wrapper.find('.payment__button').trigger('click') await wrapper.find('.btn--primary').trigger('click')
expect(mockPayOrder).toHaveBeenCalledWith('order-1') expect(mockPayOrder).toHaveBeenCalledWith('order-1')
}) })
@ -105,7 +105,7 @@ describe('PaymentRedirect', () => {
}) })
const { wrapper, router } = await mountPage() const { wrapper, router } = await mountPage()
await wrapper.find('.payment__button').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders') expect(router.currentRoute.value.name).toBe('orders')
@ -116,7 +116,7 @@ describe('PaymentRedirect', () => {
mockPayOrder.mockRejectedValue(new Error('Network error')) mockPayOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await wrapper.find('.payment__button').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte genomföra betalningen') expect(wrapper.text()).toContain('Kunde inte genomföra betalningen')
@ -127,7 +127,7 @@ describe('PaymentRedirect', () => {
mockPayOrder.mockImplementation(() => new Promise(() => {})) mockPayOrder.mockImplementation(() => new Promise(() => {}))
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const button = wrapper.find('.payment__button') const button = wrapper.find('.btn--primary')
await button.trigger('click') await button.trigger('click')
expect(button.attributes('disabled')).toBeDefined() expect(button.attributes('disabled')).toBeDefined()

View file

@ -24,7 +24,6 @@ describe('TemplatePicker', () => {
expect(wrapper.emitted('select')).toHaveLength(1) expect(wrapper.emitted('select')).toHaveLength(1)
expect(wrapper.emitted('select')![0][0]).toMatchObject({ expect(wrapper.emitted('select')![0][0]).toMatchObject({
name: 'Komplimang', name: 'Komplimang',
icon: '🌟',
}) })
}) })

View file

@ -0,0 +1,409 @@
/* ── Reset ────────────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
img,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
/* ── Design Tokens ───────────────────────────────────────────────────── */
:root {
/* ink / text */
--color-ink: #111827;
--color-muted: #667085;
--color-soft: #9ca3af;
/* surfaces */
--color-paper: #fdfaf5;
--color-surface: #ffffff;
--color-surface-tint: #f5f0ff;
/* brand */
--color-primary: #1d4ed8;
--color-primary-dark: #1e3a8a;
--color-primary-soft: #dbeafe;
--color-primary-ring: rgba(29, 78, 216, 0.22);
/* accent */
--color-accent: #0f766e;
--color-accent-soft: #ccfbf1;
/* semantic */
--color-success: #15803d;
--color-success-soft: #f0fdf4;
--color-warning: #b45309;
--color-warning-soft: #fffbeb;
--color-danger: #b91c1c;
--color-danger-soft: #fef2f2;
/* borders & dividers */
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
/* spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
/* radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07),
0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.08),
0 8px 10px -6px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.12);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06),
0 1px 2px rgba(0, 0, 0, 0.04);
/* typography */
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
/* transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
}
/* ── Body ────────────────────────────────────────────────────────────── */
body {
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.6;
color: var(--color-ink);
background: var(--color-paper);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Typography ──────────────────────────────────────────────────────── */
h1,
h2,
h3,
h4 {
line-height: 1.25;
font-weight: 700;
color: var(--color-ink);
}
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.375rem;
}
h3 {
font-size: 1.125rem;
}
h4 {
font-size: 1rem;
}
p {
color: var(--color-muted);
}
/* ── Links ───────────────────────────────────────────────────────────── */
a[href] {
color: var(--color-primary);
transition: color var(--transition-fast);
}
a[href]:hover {
color: var(--color-primary-dark);
}
/* ── Buttons as links ────────────────────────────────────────────────── */
.btn[href],
.btn[href]:hover {
color: inherit;
}
.btn--primary[href],
.btn--primary[href]:hover {
color: #fff;
}
.btn--success[href],
.btn--success[href]:hover {
color: #fff;
}
.btn--accent[href],
.btn--accent[href]:hover {
color: #fff;
}
/* ── Focus ───────────────────────────────────────────────────────────── */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* ── Container ───────────────────────────────────────────────────────── */
.container {
width: 100%;
max-width: 72rem;
margin-inline: auto;
padding-inline: var(--space-lg);
}
.container--narrow {
max-width: 36rem;
}
.container--wide {
max-width: 80rem;
}
/* ── Surface card ────────────────────────────────────────────────────── */
.surface-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
box-shadow: var(--shadow-card);
}
/* ── buttons ─────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: 0.75rem 1.5rem;
font-size: 0.9375rem;
font-weight: 600;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast),
transform var(--transition-fast), box-shadow var(--transition-fast);
white-space: nowrap;
}
.btn:active {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn--primary {
background: var(--color-primary);
color: #fff;
}
.btn--primary:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.btn--success {
background: var(--color-success);
color: #fff;
}
.btn--success:hover:not(:disabled) {
background: #166534;
}
.btn--accent {
background: var(--color-accent);
color: #fff;
}
.btn--accent:hover:not(:disabled) {
background: #0284c7;
}
.btn--ghost {
background: transparent;
color: var(--color-ink);
border: 1px solid var(--color-border);
}
.btn--ghost:hover:not(:disabled) {
background: var(--color-border-light);
}
.btn--sm {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
}
.btn--lg {
padding: 1rem 2rem;
font-size: 1.0625rem;
}
/* ── Form fields ─────────────────────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.field__label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-ink);
}
.field__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.field__input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-ring);
}
.field__input--error {
border-color: var(--color-danger);
}
.field__input--error:focus {
box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.2);
}
.field__error {
font-size: 0.8125rem;
color: var(--color-danger);
}
.field__hint {
font-size: 0.8125rem;
color: var(--color-soft);
}
/* ── Badge ───────────────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: var(--radius-full);
white-space: nowrap;
}
.badge--muted {
background: var(--color-border-light);
color: var(--color-muted);
}
.badge--primary {
background: var(--color-primary-soft);
color: var(--color-primary-dark);
}
.badge--success {
background: var(--color-success-soft);
color: var(--color-success);
}
.badge--warning {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.badge--danger {
background: var(--color-danger-soft);
color: var(--color-danger);
}
/* ── Message boxes ───────────────────────────────────────────────────── */
.message {
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.message--error {
background: var(--color-danger-soft);
border: 1px solid #fecaca;
color: var(--color-danger);
}
.message--info {
background: var(--color-primary-soft);
border: 1px solid #ddd6fe;
color: var(--color-primary-dark);
}
.message--success {
background: var(--color-success-soft);
border: 1px solid #bbf7d0;
color: var(--color-success);
}
/* ── Divider ─────────────────────────────────────────────────────────── */
.divider {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-lg) 0;
}
/* ── Eyebrow ─────────────────────────────────────────────────────────── */
.eyebrow {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-primary);
}
/* ── Utility ─────────────────────────────────────────────────────────── */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.text-center {
text-align: center;
}
.text-muted {
color: var(--color-muted);
}
.text-sm {
font-size: 0.875rem;
}
.text-xs {
font-size: 0.75rem;
}

View file

@ -4,39 +4,67 @@ import { RouterLink } from 'vue-router'
<template> <template>
<footer class="app-footer"> <footer class="app-footer">
<nav class="app-footer__links"> <div class="app-footer__inner">
<RouterLink to="/om" class="app-footer__link">Om oss</RouterLink> <p class="app-footer__tagline">
<RouterLink to="/kontakt" class="app-footer__link">Kontakt</RouterLink> Bilhej hjälper dig att skicka brev till bilägare via
<RouterLink to="/integritetspolicy" class="app-footer__link" registreringsnummer.
>Integritetspolicy</RouterLink </p>
> <nav class="app-footer__links">
<RouterLink to="/villkor" class="app-footer__link">Villkor</RouterLink> <RouterLink to="/om" class="app-footer__link">Om oss</RouterLink>
</nav> <RouterLink to="/kontakt" class="app-footer__link">Kontakt</RouterLink>
<RouterLink to="/integritetspolicy" class="app-footer__link"
>Integritetspolicy</RouterLink
>
<RouterLink to="/villkor" class="app-footer__link">Villkor</RouterLink>
</nav>
<p class="app-footer__copy">
&copy; {{ new Date().getFullYear() }} Bilhej
</p>
</div>
</footer> </footer>
</template> </template>
<style scoped> <style scoped>
.app-footer { .app-footer {
background: #f7fafc; background: var(--color-surface);
border-top: 1px solid #e2e8f0; border-top: 1px solid var(--color-border);
padding: 1.5rem; }
.app-footer__inner {
max-width: 72rem;
margin: 0 auto;
padding: var(--space-xl) var(--space-lg);
text-align: center; text-align: center;
} }
.app-footer__tagline {
color: var(--color-muted);
font-size: 0.875rem;
margin: 0 0 var(--space-lg) 0;
}
.app-footer__links { .app-footer__links {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 2rem; gap: var(--space-xl);
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: var(--space-lg);
} }
.app-footer__link { .app-footer__link {
color: #718096; color: var(--color-soft);
text-decoration: none; text-decoration: none;
font-size: 0.8125rem; font-size: 0.8125rem;
transition: color var(--transition-fast);
} }
.app-footer__link:hover { .app-footer__link:hover {
color: #1a202c; color: var(--color-ink);
}
.app-footer__copy {
color: var(--color-soft);
font-size: 0.75rem;
margin: 0;
} }
</style> </style>

View file

@ -1,83 +1,170 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
function handleLogout() {
auth.logout()
router.push('/')
}
</script> </script>
<template> <template>
<header class="app-header"> <header class="app-header">
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink> <div class="app-header__inner">
<nav class="app-header__nav"> <RouterLink to="/" class="app-header__logo">
<RouterLink to="/" class="app-header__link">Hem</RouterLink> <svg
<template v-if="!auth.isAuthenticated"> class="app-header__logo-icon"
<RouterLink to="/logga-in" class="app-header__link" viewBox="0 0 24 24"
>Logga in</RouterLink fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
> >
<RouterLink to="/registrera" class="app-header__link" <rect
>Registrera</RouterLink x="2"
> y="5"
</template> width="20"
<template v-else> height="14"
<RouterLink to="/orders" class="app-header__link" rx="2"
>Mina beställningar</RouterLink stroke="currentColor"
> stroke-width="2"
<span class="app-header__email">{{ auth.email }}</span> />
<button class="app-header__logout" @click="auth.logout()"> <path
Logga ut d="M2 7l10 6 10-6"
</button> stroke="currentColor"
</template> stroke-width="2"
</nav> stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Bilhej
</RouterLink>
<nav class="app-header__nav">
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
<template v-if="!auth.isAuthenticated">
<RouterLink to="/logga-in" class="app-header__link"
>Logga in</RouterLink
>
<RouterLink to="/registrera" class="app-header__link"
>Registrera</RouterLink
>
</template>
<template v-else>
<RouterLink
v-if="auth.isAdmin"
to="/admin"
class="app-header__link app-header__link--admin"
>Admin</RouterLink
>
<RouterLink to="/orders" class="app-header__link"
>Mina beställningar</RouterLink
>
<span class="app-header__email">{{ auth.email }}</span>
<button class="app-header__logout" @click="handleLogout">
Logga ut
</button>
</template>
</nav>
</div>
</header> </header>
</template> </template>
<style scoped> <style scoped>
.app-header { .app-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(253, 250, 245, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border);
}
.app-header__inner {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 1.5rem; max-width: 72rem;
border-bottom: 1px solid #e2e8f0; margin: 0 auto;
background: #fff; padding: 0.875rem var(--space-lg);
} }
.app-header__logo { .app-header__logo {
font-size: 1.25rem; display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 1.125rem;
font-weight: 700; font-weight: 700;
color: #1a202c; color: var(--color-ink);
text-decoration: none; text-decoration: none;
} }
.app-header__logo-icon {
width: 1.5rem;
height: 1.5rem;
}
.app-header__nav { .app-header__nav {
display: flex; display: flex;
gap: 1rem; align-items: center;
gap: var(--space-sm);
} }
.app-header__link { .app-header__link {
color: #4a5568; padding: 0.4rem 0.875rem;
text-decoration: none;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500;
color: var(--color-muted);
text-decoration: none;
border-radius: var(--radius-full);
transition:
color var(--transition-fast),
background var(--transition-fast);
} }
.app-header__link:hover { .app-header__link:hover,
color: #1a202c; .app-header__link.router-link-active {
color: var(--color-primary-dark);
background: var(--color-primary-soft);
}
.app-header__link--admin {
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 600;
}
.app-header__link--admin:hover {
background: #e9d5ff;
color: var(--color-primary-dark);
} }
.app-header__email { .app-header__email {
color: #4a5568; color: var(--color-muted);
font-size: 0.875rem; font-size: 0.8125rem;
padding: 0 0.5rem;
} }
.app-header__logout { .app-header__logout {
background: none; background: none;
border: none; border: 1px solid var(--color-border);
color: #4a5568; color: var(--color-muted);
font-size: 0.875rem; font-size: 0.8125rem;
font-weight: 500;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0.35rem 0.875rem;
border-radius: var(--radius-full);
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
} }
.app-header__logout:hover { .app-header__logout:hover {
color: #1a202c; color: var(--color-danger);
border-color: var(--color-danger);
background: var(--color-danger-soft);
} }
</style> </style>

View file

@ -36,7 +36,7 @@ watch(isValid, (valid) => {
<template> <template>
<div class="plate-input"> <div class="plate-input">
<label for="plate" class="plate-input__label">Registreringsnummer</label> <label for="plate" class="field__label">Registreringsnummer</label>
<input <input
id="plate" id="plate"
type="text" type="text"
@ -46,11 +46,13 @@ watch(isValid, (valid) => {
:value="plate" :value="plate"
class="plate-input__field" class="plate-input__field"
:class="{ 'plate-input__field--error': showError }" :class="{ 'plate-input__field--error': showError }"
:aria-invalid="showError"
aria-describedby="plate-error"
placeholder="ABC 123" placeholder="ABC 123"
maxlength="7" maxlength="7"
@input="handleInput" @input="handleInput"
/> />
<p v-if="showError" class="plate-input__error"> <p v-if="showError" id="plate-error" class="field__error">
Ange ett giltigt registreringsnummer Ange ett giltigt registreringsnummer
</p> </p>
</div> </div>
@ -60,47 +62,36 @@ watch(isValid, (valid) => {
.plate-input { .plate-input {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: var(--space-sm);
width: 100%; width: 100%;
} }
.plate-input__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.plate-input__field { .plate-input__field {
width: 100%; width: 100%;
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
font-size: 1.5rem; font-size: 1.5rem;
font-family: monospace; font-family: var(--font-mono);
letter-spacing: 0.15em; letter-spacing: 0.15em;
text-transform: uppercase; text-transform: uppercase;
border: 2px solid #cbd5e0; background: var(--color-surface);
border-radius: 0.5rem; border: 2px solid var(--color-border);
border-radius: var(--radius-md);
outline: none; outline: none;
transition: border-color 0.15s ease; transition:
box-sizing: border-box; border-color var(--transition-fast),
box-shadow var(--transition-fast);
} }
.plate-input__field:focus { .plate-input__field:focus {
border-color: #4299e1; border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25); box-shadow: 0 0 0 3px var(--color-primary-ring);
} }
.plate-input__field--error { .plate-input__field--error {
border-color: #e53e3e; border-color: var(--color-danger);
} }
.plate-input__field--error:focus { .plate-input__field--error:focus {
border-color: #e53e3e; box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.2);
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.25);
}
.plate-input__error {
margin: 0;
font-size: 0.8125rem;
color: #e53e3e;
} }
</style> </style>

View file

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { templates, type LetterTemplate } from '@/data/templates' import { templates, type LetterTemplate } from '@/data/templates'
const emit = defineEmits<{ const emit = defineEmits<{
@ -6,21 +7,59 @@ const emit = defineEmits<{
(e: 'close'): void (e: 'close'): void
}>() }>()
const dialogRef = ref<HTMLDivElement | null>(null)
function handleSelect(template: LetterTemplate) { function handleSelect(template: LetterTemplate) {
emit('select', template) emit('select', template)
emit('close') emit('close')
} }
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
emit('close')
}
}
onMounted(() => {
dialogRef.value?.focus()
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script> </script>
<template> <template>
<div class="modal-overlay" @click.self="emit('close')"> <div class="modal-overlay" @click.self="emit('close')">
<div class="modal"> <div
ref="dialogRef"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
>
<div class="modal__header"> <div class="modal__header">
<h2 class="modal__title">Välj en mall</h2> <h2 id="modal-title" class="modal__title">Välj en mall</h2>
<button class="modal__close" @click="emit('close')">&times;</button> <button class="modal__close" aria-label="Stäng" @click="emit('close')">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div> </div>
<p class="modal__subtitle"> <p class="modal__subtitle">
Klicka en mall för att fylla i meddelandetexten. Välj en starttext. Du kan ändra allt innan du skickar.
</p> </p>
<div class="modal__grid"> <div class="modal__grid">
<button <button
@ -29,7 +68,6 @@ function handleSelect(template: LetterTemplate) {
class="modal__card" class="modal__card"
@click="handleSelect(t)" @click="handleSelect(t)"
> >
<span class="modal__card-icon">{{ t.icon }}</span>
<span class="modal__card-name">{{ t.name }}</span> <span class="modal__card-name">{{ t.name }}</span>
</button> </button>
</div> </div>
@ -46,84 +84,88 @@ function handleSelect(template: LetterTemplate) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
padding: 1rem; padding: var(--space-md);
} }
.modal { .modal {
background: #fff; background: var(--color-surface);
border-radius: 1rem; border-radius: var(--radius-xl);
width: 100%; width: 100%;
max-width: 28rem; max-width: 28rem;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-xl);
outline: none;
} }
.modal__header { .modal__header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1.5rem 1.5rem 0; padding: var(--space-lg) var(--space-lg) 0;
} }
.modal__title { .modal__title {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #1a202c; color: var(--color-ink);
} }
.modal__close { .modal__close {
background: none; background: none;
border: none; border: none;
font-size: 1.5rem; font-size: 1.5rem;
color: #a0aec0; color: var(--color-soft);
cursor: pointer; cursor: pointer;
padding: 0.25rem 0.5rem; padding: 0.25rem;
border-radius: 0.25rem; border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
transition: transition:
color 0.15s, color var(--transition-fast),
background 0.15s; background var(--transition-fast);
} }
.modal__close:hover { .modal__close:hover {
color: #4a5568; color: var(--color-ink);
background: #f7fafc; background: var(--color-border-light);
} }
.modal__subtitle { .modal__subtitle {
margin: 0.5rem 0 0; margin: var(--space-sm) 0 0;
padding: 0 1.5rem; padding: 0 var(--space-lg);
font-size: 0.875rem; font-size: 0.875rem;
color: #718096; color: var(--color-muted);
} }
.modal__grid { .modal__grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.75rem; gap: var(--space-sm);
padding: 1.25rem 1.5rem 1.5rem; padding: var(--space-lg);
} }
.modal__card { .modal__card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; gap: var(--space-sm);
padding: 1.25rem 0.75rem; padding: var(--space-lg) var(--space-sm);
background: #f7fafc; background: var(--color-primary-soft);
border: 2px solid #e2e8f0; border: 2px solid transparent;
border-radius: 0.75rem; border-radius: var(--radius-lg);
cursor: pointer; cursor: pointer;
transition: transition:
border-color 0.15s, border-color var(--transition-fast),
background 0.15s, background var(--transition-fast),
transform 0.1s; transform 0.1s;
font-family: inherit; font-family: inherit;
} }
.modal__card:hover { .modal__card:hover {
border-color: #4299e1; border-color: var(--color-primary);
background: #ebf8ff; background: #dbeafe;
transform: translateY(-1px); transform: translateY(-1px);
} }
@ -131,14 +173,10 @@ function handleSelect(template: LetterTemplate) {
transform: translateY(0); transform: translateY(0);
} }
.modal__card-icon {
font-size: 2rem;
}
.modal__card-name { .modal__card-name {
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 600; font-weight: 600;
color: #4a5568; color: var(--color-ink);
text-align: center; text-align: center;
line-height: 1.3; line-height: 1.3;
} }

View file

@ -16,50 +16,62 @@ defineProps<{
<template> <template>
<div class="vehicle-info"> <div class="vehicle-info">
<div v-if="vehicle" class="vehicle-info__card"> <div
<p class="vehicle-info__card-text"> v-if="vehicle"
class="vehicle-info__card vehicle-info__card--found"
role="status"
>
<p class="vehicle-info__text">
{{ vehicle.make }} {{ vehicle.model }} ({{ vehicle.year }}) &mdash; {{ vehicle.make }} {{ vehicle.model }} ({{ vehicle.year }}) &mdash;
{{ vehicle.color }} {{ vehicle.color }}
</p> </p>
</div> </div>
<div v-else-if="loading" class="vehicle-info__loading">Söker...</div> <div v-else-if="loading" class="vehicle-info__loading" role="status">
<div v-else-if="notFound" class="vehicle-info__not-found"> Söker...
<p>Inget fordon hittades</p> </div>
<div
v-else-if="notFound"
class="vehicle-info__card vehicle-info__card--missing"
role="status"
>
<p class="vehicle-info__text">Inget fordon hittades</p>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.vehicle-info { .vehicle-info {
margin-top: 0.75rem; margin-top: var(--space-md);
} }
.vehicle-info__loading { .vehicle-info__loading {
color: #718096; color: var(--color-muted);
font-size: 0.875rem; font-size: 0.875rem;
} }
.vehicle-info__card { .vehicle-info__card {
padding: 1rem; padding: var(--space-md);
background: #f0fff4; border-radius: var(--radius-md);
border: 1px solid #c6f6d5; font-size: 0.875rem;
border-radius: 0.5rem;
} }
.vehicle-info__card-text { .vehicle-info__card--found {
background: var(--color-success-soft);
border: 1px solid #bbf7d0;
}
.vehicle-info__card--missing {
background: var(--color-warning-soft);
border: 1px solid #fde68a;
}
.vehicle-info__text {
margin: 0; margin: 0;
font-weight: 500; font-weight: 500;
color: var(--color-ink);
} }
.vehicle-info__not-found { .vehicle-info__card--missing .vehicle-info__text {
padding: 1rem; color: var(--color-warning);
background: #fffaf0;
border: 1px solid #feebc8;
border-radius: 0.5rem;
}
.vehicle-info__not-found p {
margin: 0;
color: #c05621;
} }
</style> </style>

View file

@ -1,13 +1,11 @@
export interface LetterTemplate { export interface LetterTemplate {
name: string name: string
icon: string
body: string body: string
} }
export const templates: LetterTemplate[] = [ export const templates: LetterTemplate[] = [
{ {
name: 'Komplimang', name: 'Komplimang',
icon: '🌟',
body: `Hej! body: `Hej!
Jag ville bara säga att din bil är jättefin! Det syns att den är väl omhändertagen och jag uppskattar verkligen att du tar hand om den bra. Jag ville bara säga att din bil är jättefin! Det syns att den är väl omhändertagen och jag uppskattar verkligen att du tar hand om den bra.
@ -16,7 +14,6 @@ Ha en trevlig dag!`,
}, },
{ {
name: 'Köpförfrågan', name: 'Köpförfrågan',
icon: '🚗',
body: `Hej! body: `Hej!
Jag är intresserad av att köpa din bil. Om du någon gång funderar att sälja den, får du gärna höra av dig. Jag är intresserad av att köpa din bil. Om du någon gång funderar att sälja den, får du gärna höra av dig.
@ -28,7 +25,6 @@ Vänliga hälsningar,
}, },
{ {
name: 'Tips / servicebehov', name: 'Tips / servicebehov',
icon: '🔧',
body: `Hej! body: `Hej!
Jag ville tipsa dig om att jag märkte att din bil behöver lite uppmärksamhet. Det kan vara bra att kolla upp det snart som möjligt. Jag ville tipsa dig om att jag märkte att din bil behöver lite uppmärksamhet. Det kan vara bra att kolla upp det snart som möjligt.
@ -37,7 +33,6 @@ Hoppas detta var till hjälp!`,
}, },
{ {
name: 'Körbeteende', name: 'Körbeteende',
icon: '🛣️',
body: `Hej! body: `Hej!
Jag ville uppmärksamma dig en situation i trafiken där jag reagerade ditt körbeteende. Jag menar inget illa utan vill bara ge en vänlig påminnelse om att vara extra uppmärksam. Jag ville uppmärksamma dig en situation i trafiken där jag reagerade ditt körbeteende. Jag menar inget illa utan vill bara ge en vänlig påminnelse om att vara extra uppmärksam.
@ -46,7 +41,6 @@ Tack för att du lyssnar!`,
}, },
{ {
name: 'Tuta / frustration', name: 'Tuta / frustration',
icon: '📢',
body: `Hej! body: `Hej!
Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir ibland, men jag ville ut för att lösa det ett trevligt sätt. Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir ibland, men jag ville ut för att lösa det ett trevligt sätt.
@ -55,7 +49,6 @@ Ha det bra!`,
}, },
{ {
name: 'Mindre parkeringsskada', name: 'Mindre parkeringsskada',
icon: '🅿️',
body: `Hej! body: `Hej!
Jag råkade skada din bil lite när jag parkerade. Det var inte meningen och jag ber om ursäkt. Jag vill gärna att vi löser det här tillsammans. Jag råkade skada din bil lite när jag parkerade. Det var inte meningen och jag ber om ursäkt. Jag vill gärna att vi löser det här tillsammans.
@ -67,7 +60,6 @@ Vänliga hälsningar,
}, },
{ {
name: 'Fritt meddelande', name: 'Fritt meddelande',
icon: '✏️',
body: '', body: '',
}, },
] ]

View file

@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './assets/styles/base.css'
const app = createApp(App) const app = createApp(App)

View file

@ -1,19 +1,39 @@
<script setup lang="ts"></script> <script setup lang="ts"></script>
<template> <template>
<div class="about"> <div class="page">
<h1>Om BilHälsning</h1> <div class="page__card">
<p> <h1>Om Bilhej</h1>
BilHälsning är en tjänst som låter dig skicka fysiska brev till <p>
fordonsägare via registreringsnummer. Bilhej är en tjänst som låter dig skicka fysiska brev till fordonsägare
</p> via registreringsnummer.
</p>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.about { .page {
max-width: 28rem; max-width: 36rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
}
.page__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
h1 {
margin: 0 0 var(--space-md) 0;
font-size: 1.5rem;
}
p {
margin: 0;
line-height: 1.7;
} }
</style> </style>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { import {
fetchAllOrders, fetchAllOrders,
updateOrderStatus, updateOrderStatus,
@ -24,13 +24,13 @@ const statusLabels: Record<string, string> = {
failed: 'Misslyckad', failed: 'Misslyckad',
} }
const statusClasses: Record<string, string> = { const statusBadge: Record<string, string> = {
pending_payment: 'badge--gray', pending_payment: 'badge--muted',
paid: 'badge--blue', paid: 'badge--primary',
lookup_started: 'badge--blue', lookup_started: 'badge--primary',
sent: 'badge--green', sent: 'badge--success',
delivered: 'badge--green', delivered: 'badge--success',
failed: 'badge--red', failed: 'badge--danger',
} }
const allStatuses = [ const allStatuses = [
@ -42,6 +42,20 @@ const allStatuses = [
'failed', 'failed',
] ]
const stats = computed(() => {
const total = orders.value.length
const paid = orders.value.filter((o) =>
['paid', 'lookup_started', 'sent', 'delivered'].includes(o.status),
).length
const pending = orders.value.filter(
(o) => o.status === 'pending_payment',
).length
const sent = orders.value.filter(
(o) => o.status === 'sent' || o.status === 'delivered',
).length
return { total, paid, pending, sent }
})
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', { return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric', year: 'numeric',
@ -107,361 +121,439 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="admin-dashboard"> <div class="admin">
<h1 class="admin-dashboard__title">Administration</h1> <h1 class="admin__title">Administration</h1>
<p class="admin-dashboard__subtitle">
Hantera beställningar, mallar och användare.
</p>
<p v-if="loading" class="admin-dashboard__loading"> <p
v-if="loading"
class="text-muted text-center admin__loading"
role="status"
>
Laddar beställningar... Laddar beställningar...
</p> </p>
<p v-else-if="error" class="admin-dashboard__error">{{ error }}</p> <div v-else-if="error" class="message message--error">{{ error }}</div>
<p v-else-if="orders.length === 0" class="admin-dashboard__empty"> <div v-else-if="orders.length === 0" class="message message--info">
Inga beställningar ännu. Inga beställningar ännu.
</p> </div>
<div v-else class="admin-dashboard__table-wrapper"> <template v-else>
<p v-if="statusError" class="admin-dashboard__status-error"> <div class="admin__stats">
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.total }}</span>
<span class="admin__stat-label">Totalt</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.paid }}</span>
<span class="admin__stat-label">Betalda</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.pending }}</span>
<span class="admin__stat-label">Väntar</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.sent }}</span>
<span class="admin__stat-label">Skickade</span>
</div>
</div>
<p
v-if="statusError"
class="message message--error admin__status-error"
role="alert"
>
{{ statusError }} {{ statusError }}
</p> </p>
<table class="admin-dashboard__table"> <div class="admin__table-wrap">
<thead> <table class="admin__table">
<tr> <thead>
<th>Datum</th> <tr>
<th>E-post</th> <th>Datum</th>
<th>Regnr</th> <th>E-post</th>
<th>Status</th> <th>Regnr</th>
<th></th> <th>Status</th>
</tr> <th></th>
</thead>
<tbody>
<template v-for="order in orders" :key="order.id">
<tr
class="admin-dashboard__row"
:class="{
'admin-dashboard__row--expanded': expandedOrderId === order.id,
}"
@click="toggleExpand(order.id)"
>
<td>{{ formatDate(order.createdAt) }}</td>
<td>{{ order.email }}</td>
<td class="admin-dashboard__plate">{{ order.plate }}</td>
<td>
<select
class="admin-dashboard__status-select"
:class="statusClasses[order.status] || 'badge--gray'"
:value="order.status"
@change="
handleStatusChange(
order.id,
($event.target as HTMLSelectElement).value,
)
"
@click.stop
>
<option v-for="s in allStatuses" :key="s" :value="s">
{{ statusLabels[s] }}
</option>
</select>
</td>
<td class="admin-dashboard__expand">
<span class="admin-dashboard__chevron">
{{ expandedOrderId === order.id ? '▼' : '▶' }}
</span>
</td>
</tr> </tr>
<tr </thead>
v-if="expandedOrderId === order.id" <tbody>
class="admin-dashboard__expanded-row" <template v-for="order in orders" :key="order.id">
> <tr
<td :colspan="5"> class="admin__row"
<div class="admin-dashboard__letter"> :class="{
<div class="admin-dashboard__letter-label">Brevtext</div> 'admin__row--expanded': expandedOrderId === order.id,
<div class="admin-dashboard__letter-text"> }"
{{ order.letterText }} >
</div> <td>{{ formatDate(order.createdAt) }}</td>
</div> <td>{{ order.email }}</td>
<td class="admin__plate">{{ order.plate }}</td>
<div class="admin-dashboard__tracking"> <td>
<div class="admin-dashboard__tracking-header"> <select
<span class="admin-dashboard__tracking-label" class="admin__status-select"
>Spårnings-ID</span :class="statusBadge[order.status] || 'badge--muted'"
:value="order.status"
@change="
handleStatusChange(
order.id,
($event.target as HTMLSelectElement).value,
)
"
@click.stop
>
<option v-for="s in allStatuses" :key="s" :value="s">
{{ statusLabels[s] }}
</option>
</select>
</td>
<td class="admin__chevron-cell">
<button
class="admin__expand-btn"
:aria-expanded="expandedOrderId === order.id"
:aria-label="
expandedOrderId === order.id
? 'Dölj detaljer'
: 'Visa detaljer'
"
@click.stop="toggleExpand(order.id)"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
> >
<a <polyline
v-if="order.trackingId" :points="
class="admin-dashboard__tracking-link" expandedOrderId === order.id
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`" ? '6 9 12 15 18 9'
target="_blank" : '9 6 15 12 9 18'
rel="noopener noreferrer" "
@click.stop />
> </svg>
Spåra hos PostNord </button>
</a> </td>
</div> </tr>
<tr
v-if="expandedOrderId === order.id"
class="admin__expanded-row"
>
<td :colspan="5">
<div class="admin__expanded-inner">
<div class="admin__section">
<div class="admin__section-label">Brevtext</div>
<div class="admin__section-body">
{{ order.letterText }}
</div>
</div>
<p v-if="trackingError" class="admin-dashboard__status-error"> <div class="admin__section">
{{ trackingError }} <div class="admin__section-header">
</p> <span class="admin__section-label">Spårnings-ID</span>
<a
v-if="order.trackingId"
class="admin__tracking-link"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank"
rel="noopener noreferrer"
@click.stop
>
Spåra hos PostNord
</a>
</div>
<div class="admin-dashboard__tracking-input-row"> <p
<input v-if="trackingError"
class="admin-dashboard__tracking-input" class="message message--error admin__tracking-error"
type="text" role="alert"
:value=" >
trackingInputValues[order.id] ?? order.trackingId ?? '' {{ trackingError }}
" </p>
placeholder="PN..."
@input=" <div class="admin__tracking-row">
trackingInputValues[order.id] = ( <label
$event.target as HTMLInputElement :for="`tracking-${order.id}`"
).value class="visually-hidden"
" >Spårnings-ID</label
@click.stop >
/> <input
<button :id="`tracking-${order.id}`"
class="admin-dashboard__tracking-save" class="admin__tracking-input"
@click.stop="handleTrackingSave(order.id)" type="text"
> :value="
Spara spårning trackingInputValues[order.id] ??
</button> order.trackingId ??
''
"
placeholder="PN..."
@input="
trackingInputValues[order.id] = (
$event.target as HTMLInputElement
).value
"
@click.stop
/>
<button
class="btn btn--primary btn--sm"
@click.stop="handleTrackingSave(order.id)"
>
Spara
</button>
</div>
</div>
</div> </div>
</div> </td>
</td> </tr>
</tr> </template>
</template> </tbody>
</tbody> </table>
</table> </div>
</div> </template>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.admin-dashboard { .admin {
max-width: 64rem; max-width: 72rem;
margin: 3rem auto 0; margin: var(--space-2xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.admin-dashboard__title { .admin__title {
margin: 0 0 0.25rem 0; margin: 0 0 var(--space-xl) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.admin-dashboard__subtitle { .admin__stats {
margin: 0 0 1.5rem 0; display: grid;
color: #718096; grid-template-columns: repeat(4, 1fr);
font-size: 0.875rem; gap: var(--space-md);
margin-bottom: var(--space-xl);
} }
.admin-dashboard__loading, .admin__stat {
.admin-dashboard__error, background: var(--color-surface);
.admin-dashboard__empty { border: 1px solid var(--color-border);
margin: 2rem 0; border-radius: var(--radius-lg);
padding: 1rem; padding: var(--space-md) var(--space-lg);
border-radius: 0.5rem;
font-size: 0.875rem;
text-align: center; text-align: center;
box-shadow: var(--shadow-sm);
} }
.admin-dashboard__loading { .admin__stat-value {
color: #718096; display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-ink);
} }
.admin-dashboard__error { .admin__stat-label {
background: #fff5f5; font-size: 0.75rem;
border: 1px solid #fed7d7; font-weight: 600;
color: #c53030; text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
margin-top: var(--space-xs);
} }
.admin-dashboard__empty { .admin__table-wrap {
background: #f7fafc; background: var(--color-surface);
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
color: #718096; border-radius: var(--radius-lg);
} overflow: hidden;
box-shadow: var(--shadow-card);
.admin-dashboard__status-error {
margin: 0 0 0.75rem 0;
padding: 0.5rem 0.75rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.375rem;
color: #c53030;
font-size: 0.8125rem;
}
.admin-dashboard__table-wrapper {
overflow-x: auto; overflow-x: auto;
} }
.admin-dashboard__table { .admin__table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.875rem; font-size: 0.875rem;
} }
.admin-dashboard__table thead { .admin__table thead {
background: #f7fafc; background: var(--color-border-light);
} }
.admin-dashboard__table th { .admin__table th {
padding: 0.75rem 1rem; padding: 0.75rem var(--space-md);
text-align: left; text-align: left;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: #718096; color: var(--color-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
border-bottom: 2px solid #e2e8f0; border-bottom: 1px solid var(--color-border);
} }
.admin-dashboard__row { .admin__row {
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid var(--color-border-light);
transition: background 0.1s; transition: background var(--transition-fast);
} }
.admin-dashboard__row:hover { .admin__row:last-child {
background: #f7fafc; border-bottom: none;
} }
.admin-dashboard__row--expanded { .admin__row:hover {
background: #ebf8ff; background: var(--color-border-light);
} }
.admin-dashboard__row td { .admin__row--expanded {
padding: 0.75rem 1rem; background: var(--color-primary-soft) !important;
color: #4a5568; }
.admin__row td {
padding: 0.75rem var(--space-md);
color: var(--color-ink);
white-space: nowrap; white-space: nowrap;
} }
.admin-dashboard__plate { .admin__plate {
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: #1a202c !important;
} }
.admin-dashboard__status-select { .admin__status-select {
display: inline-block; padding: 0.2rem 0.5rem;
padding: 0.25rem 0.5rem; border-radius: var(--radius-sm);
border-radius: 0.375rem; border: 1px solid var(--color-border);
border: 1px solid #e2e8f0;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: #4a5568; color: var(--color-ink);
outline: none; outline: none;
cursor: pointer; cursor: pointer;
background: #fff; background: var(--color-surface);
} }
.admin-dashboard__status-select:focus { .admin__status-select:focus {
border-color: #4299e1; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2); box-shadow: 0 0 0 2px var(--color-primary-ring);
} }
.admin-dashboard__expand { .admin__chevron-cell {
text-align: center; text-align: center;
width: 2rem; width: 2rem;
} }
.admin-dashboard__chevron { .admin__expand-btn {
font-size: 0.625rem; background: none;
color: #a0aec0; border: none;
color: var(--color-soft);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color var(--transition-fast),
background var(--transition-fast);
} }
.admin-dashboard__expanded-row td { .admin__expand-btn:hover {
color: var(--color-ink);
background: var(--color-border-light);
}
.admin__expanded-row td {
padding: 0; padding: 0;
background: #f7fafc; background: var(--color-surface);
} }
.admin-dashboard__letter { .admin__expanded-inner {
padding: 1rem 1.25rem; padding: var(--space-lg);
border-top: 1px solid #e2e8f0; display: flex;
flex-direction: column;
gap: var(--space-lg);
border-top: 1px solid var(--color-border-light);
} }
.admin-dashboard__letter-label { .admin__section {
padding: var(--space-md);
background: var(--color-border-light);
border-radius: var(--radius-md);
}
.admin__section-label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: #a0aec0; color: var(--color-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin-bottom: 0.5rem; margin-bottom: var(--space-sm);
} }
.admin-dashboard__letter-text { .admin__section-body {
font-size: 0.875rem; font-size: 0.875rem;
color: #4a5568; color: var(--color-ink);
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
} }
.admin-dashboard__tracking { .admin__section-header {
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
}
.admin-dashboard__tracking-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 0.5rem;
} }
.admin-dashboard__tracking-label { .admin__tracking-link {
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.admin-dashboard__tracking-link {
font-size: 0.8125rem; font-size: 0.8125rem;
color: #4299e1; color: var(--color-primary);
text-decoration: none; text-decoration: none;
font-weight: 500;
} }
.admin-dashboard__tracking-link:hover { .admin__tracking-link:hover {
text-decoration: underline; text-decoration: underline;
} }
.admin-dashboard__tracking-input-row { .admin__tracking-row {
display: flex; display: flex;
gap: 0.5rem; gap: var(--space-sm);
margin-top: var(--space-sm);
} }
.admin-dashboard__tracking-input { .admin__tracking-input {
flex: 1; flex: 1;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
border-radius: 0.375rem; border-radius: var(--radius-sm);
font-size: 0.8125rem; font-size: 0.8125rem;
color: #4a5568; color: var(--color-ink);
outline: none; outline: none;
transition: border-color var(--transition-fast);
} }
.admin-dashboard__tracking-input:focus { .admin__tracking-input:focus {
border-color: #4299e1; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2); box-shadow: 0 0 0 2px var(--color-primary-ring);
} }
.admin-dashboard__tracking-save { .admin__loading {
padding: 0.5rem 1rem; padding: var(--space-2xl) 0;
border: none; }
border-radius: 0.375rem;
background: #4299e1; .admin__status-error {
color: #fff; margin-bottom: var(--space-md);
}
.admin__tracking-error {
margin-bottom: var(--space-sm);
padding: var(--space-sm) var(--space-md);
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
} }
.admin-dashboard__tracking-save:hover { @media (max-width: 768px) {
background: #3182ce; .admin__stats {
grid-template-columns: repeat(2, 1fr);
}
} }
</style> </style>

View file

@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders' import { createOrder } from '@/api/orders'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
import { RouterLink } from 'vue-router'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -21,7 +22,7 @@ const canSubmit = computed(
) )
const GDPR_FOOTER = const GDPR_FOOTER =
'Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se' 'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se'
function handleTemplateSelect(template: LetterTemplate) { function handleTemplateSelect(template: LetterTemplate) {
letterText.value = template.body letterText.value = template.body
@ -50,61 +51,75 @@ async function handleSubmit() {
<template> <template>
<div class="compose"> <div class="compose">
<h1 class="compose__title">Skriv ditt brev</h1> <div v-if="plate" class="compose__layout">
<p v-if="plate" class="compose__plate"> <div class="compose__editor">
Registreringsnummer: <strong>{{ plate }}</strong> <h1 class="compose__title">Skriv ditt brev</h1>
</p> <p class="compose__plate-badge">
<p v-if="!plate" class="compose__error"> <span class="compose__plate-label">Regnr</span>
Inget registreringsnummer valt. <span class="compose__plate-value">{{ plate }}</span>
<RouterLink to="/"> tillbaka</RouterLink>
</p>
<form v-if="plate" class="compose__form" @submit.prevent="handleSubmit">
<div class="compose__field">
<div class="compose__label-row">
<label for="letter" class="compose__label">Ditt meddelande</label>
<button
type="button"
class="compose__templates-btn"
@click="showPicker = true"
>
Visa mallar
</button>
</div>
<textarea
id="letter"
v-model="letterText"
class="compose__textarea"
:maxlength="maxChars"
rows="10"
placeholder="Skriv ditt meddelande här..."
></textarea>
<p
class="compose__counter"
:class="{ 'compose__counter--warn': charCount > 900 }"
>
{{ charCount }} / {{ maxChars }} tecken
</p> </p>
<form class="compose__form" @submit.prevent="handleSubmit">
<div class="field">
<div class="compose__label-row">
<label for="letter" class="field__label">Ditt meddelande</label>
<button
type="button"
class="compose__templates-btn"
@click="showPicker = true"
>
Visa mallar
</button>
</div>
<textarea
id="letter"
v-model="letterText"
class="field__input compose__textarea"
:maxlength="maxChars"
rows="12"
placeholder="Skriv ditt meddelande här..."
></textarea>
<p
class="field__hint compose__counter"
:class="{ 'compose__counter--warn': charCount > 900 }"
>
{{ charCount }} / {{ maxChars }} tecken
</p>
</div>
<div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn--primary btn--lg compose__submit"
:disabled="!canSubmit"
>
{{ submitting ? 'Skickar...' : 'Fortsätt till betalning' }}
</button>
</form>
</div> </div>
<div class="compose__preview"> <div class="compose__preview">
<h2 class="compose__preview-title">Förhandsvisning</h2> <h2 class="compose__preview-title">Förhandsvisning</h2>
<div class="compose__preview-page"> <div class="compose__preview-page">
<p class="compose__preview-plate">Registreringsnummer: {{ plate }}</p> <p class="compose__preview-plate-label">
<p class="compose__preview-body" style="white-space: pre-wrap"> Registreringsnummer: {{ plate }}
</p>
<p class="compose__preview-body">
{{ letterText }} {{ letterText }}
</p> </p>
<hr class="compose__preview-divider" /> <hr class="compose__preview-divider" />
<p class="compose__preview-footer">{{ GDPR_FOOTER }}</p> <p class="compose__preview-footer-text">{{ GDPR_FOOTER }}</p>
</div> </div>
</div> </div>
</div>
<p v-if="errorMessage" class="compose__api-error">{{ errorMessage }}</p> <div v-else class="message message--error compose__error">
Inget registreringsnummer valt.
<button type="submit" class="compose__submit" :disabled="!canSubmit"> <RouterLink to="/"> tillbaka</RouterLink>
{{ submitting ? 'Skickar...' : 'Skicka brev (49 kr)' }} </div>
</button>
</form>
<TemplatePicker <TemplatePicker
v-if="showPicker" v-if="showPicker"
@ -115,59 +130,50 @@ async function handleSubmit() {
</template> </template>
<style scoped> <style scoped>
.compose { .compose__layout {
max-width: 28rem; display: grid;
margin: 3rem auto 0; grid-template-columns: 1fr 1fr;
padding: 0 1rem; gap: var(--space-xl);
align-items: start;
max-width: 56rem;
margin: var(--space-2xl) auto 0;
padding: 0 var(--space-lg);
} }
.compose__title { .compose__title {
margin: 0 0 0.25rem 0; margin: 0 0 var(--space-lg) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.compose__plate { .compose__plate-badge {
margin: 0 0 1.5rem 0; display: inline-flex;
color: #4a5568; align-items: center;
font-size: 0.875rem; gap: var(--space-sm);
background: var(--color-primary-soft);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-full);
margin: 0 0 var(--space-lg) 0;
} }
.compose__error { .compose__plate-label {
margin: 2rem 0; font-size: 0.75rem;
padding: 1rem; font-weight: 600;
background: #fff5f5; text-transform: uppercase;
border: 1px solid #fed7d7; color: var(--color-muted);
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
} }
.compose__error a { .compose__plate-value {
color: #4299e1; font-size: 0.9375rem;
text-decoration: none; font-weight: 700;
} letter-spacing: 0.08em;
color: var(--color-primary-dark);
.compose__error a:hover {
text-decoration: underline;
} }
.compose__form { .compose__form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: var(--space-md);
}
.compose__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.compose__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
} }
.compose__label-row { .compose__label-row {
@ -177,129 +183,112 @@ async function handleSubmit() {
} }
.compose__templates-btn { .compose__templates-btn {
background: #ebf8ff; background: var(--color-primary-soft);
border: 1px solid #bee3f8; border: 1px solid #ddd6fe;
color: #2b6cb0; color: var(--color-primary-dark);
font-size: 0.8125rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
padding: 0.375rem 0.875rem; padding: 0.3rem 0.75rem;
border-radius: 9999px; border-radius: var(--radius-full);
transition: transition:
background 0.15s, background var(--transition-fast),
border-color 0.15s; border-color var(--transition-fast);
} }
.compose__templates-btn:hover { .compose__templates-btn:hover {
background: #bee3f8; background: #e9d5ff;
border-color: #90cdf4; border-color: #c4b5fd;
} }
.compose__textarea { .compose__textarea {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
font-family: inherit;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
resize: vertical; resize: vertical;
transition: border-color 0.15s ease; min-height: 10rem;
box-sizing: border-box; font-family: inherit;
}
.compose__textarea:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
} }
.compose__counter { .compose__counter {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
text-align: right; text-align: right;
} }
.compose__counter--warn { .compose__counter--warn {
color: #e53e3e; color: var(--color-danger) !important;
}
.compose__preview {
margin-top: 0.5rem;
}
.compose__preview-title {
margin: 0 0 0.75rem 0;
font-size: 1rem;
color: #4a5568;
}
.compose__preview-page {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 2rem 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.9375rem;
line-height: 1.6;
color: #2d3748;
}
.compose__preview-plate {
margin: 0 0 1.5rem 0;
font-family: monospace;
font-size: 0.875rem;
color: #718096;
}
.compose__preview-body {
margin: 0 0 1.5rem 0;
min-height: 4rem;
}
.compose__preview-divider {
margin: 1.5rem 0;
border: none;
border-top: 1px solid #e2e8f0;
}
.compose__preview-footer {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
line-height: 1.5;
}
.compose__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
} }
.compose__submit { .compose__submit {
width: 100%; width: 100%;
padding: 0.875rem 1.5rem; }
background: #38a169;
color: #fff; .compose__error {
border: none; max-width: 28rem;
border-radius: 0.5rem; margin: var(--space-2xl) auto;
}
.compose__preview-body {
white-space: pre-wrap;
}
.compose__preview {
position: sticky;
top: 5rem;
}
.compose__preview-title {
margin: 0 0 var(--space-md) 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; color: var(--color-muted);
cursor: pointer;
transition: background 0.15s ease;
} }
.compose__submit:hover:not(:disabled) { .compose__preview-page {
background: #2f855a; background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
box-shadow: var(--shadow-lg);
font-family: var(--font-serif);
font-size: 0.9375rem;
line-height: 1.7;
color: var(--color-ink);
min-height: 20rem;
} }
.compose__submit:disabled { .compose__preview-plate-label {
opacity: 0.5; margin: 0 0 var(--space-lg) 0;
cursor: not-allowed; font-family: var(--font-sans);
font-size: 0.8125rem;
color: var(--color-muted);
}
.compose__preview-body {
margin: 0 0 var(--space-lg) 0;
}
.compose__preview-divider {
margin: var(--space-lg) 0;
border: none;
border-top: 1px solid var(--color-border);
}
.compose__preview-footer-text {
margin: 0;
font-size: 0.75rem;
color: var(--color-soft);
font-family: var(--font-sans);
line-height: 1.5;
}
.message a {
color: var(--color-primary);
text-decoration: underline;
}
@media (max-width: 768px) {
.compose__layout {
grid-template-columns: 1fr;
}
.compose__preview {
position: static;
}
} }
</style> </style>

View file

@ -1,19 +1,39 @@
<script setup lang="ts"></script> <script setup lang="ts"></script>
<template> <template>
<div class="contact"> <div class="page">
<h1>Kontakta oss</h1> <div class="page__card">
<p> <h1>Kontakta oss</h1>
Har du frågor eller feedback? Hör av dig till oss <p>
<a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>. Har du frågor eller feedback? Hör av dig till oss
</p> <a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>.
</p>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.contact { .page {
max-width: 28rem; max-width: 36rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
}
.page__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
h1 {
margin: 0 0 var(--space-md) 0;
font-size: 1.5rem;
}
p {
margin: 0;
line-height: 1.7;
} }
</style> </style>

View file

@ -37,53 +37,289 @@ function handleLookup(lookedUpPlate: string) {
<template> <template>
<div class="home"> <div class="home">
<p class="home__subtitle">Skicka ett brev till en fordonsägare</p> <section class="home__hero">
<div class="home__hero-content">
<p class="home__eyebrow">Brev via registreringsnummer</p>
<h1 class="home__headline">Skicka ett brev<br />till en bilägare</h1>
<p class="home__lead">
Skriv ett respektfullt meddelande, ange registreringsnumret och låt
Bilhej posta brevet åt dig.
</p>
</div>
<PlateInput v-model="plate" @lookup="handleLookup" /> <div class="home__card">
<PlateInput v-model="plate" @lookup="handleLookup" />
<VehicleInfo <VehicleInfo
:vehicle="vehicle" :vehicle="vehicle"
:loading="lookingUp" :loading="lookingUp"
:not-found="notFound" :not-found="notFound"
:plate="plate" :plate="plate"
/> />
<RouterLink <RouterLink
v-if="vehicle" v-if="vehicle"
:to="{ name: 'compose', query: { plate } }" :to="{ name: 'compose', query: { plate } }"
class="home__cta" class="btn btn--primary btn--lg home__cta"
> >
Skicka ett brev till ägaren Fortsätt till brevet
</RouterLink> </RouterLink>
</div>
</section>
<section class="home__uses">
<div class="home__uses-inner">
<h2 class="home__uses-title">Vanliga anledningar</h2>
<div class="home__uses-grid">
<div class="home__use">
<h3>Vill köpa bilen</h3>
<p>Skicka en förfrågan utan att leta efter ägaren själv.</p>
</div>
<div class="home__use">
<h3>Tipsa om något</h3>
<p>
Berätta om lampor, däck eller annat som ägaren kan vilja veta.
</p>
</div>
<div class="home__use">
<h3>Skicka en komplimang</h3>
<p>En enkel hälsning till någon som tar hand om sin bil.</p>
</div>
</div>
</div>
</section>
<section class="home__steps">
<div class="home__steps-inner">
<h2 class="home__steps-title"> fungerar det</h2>
<div class="home__steps-grid">
<div class="home__step">
<span class="home__step-number">1</span>
<h3>Ange registreringsnummer</h3>
<p>
Vi visar grundläggande fordonsinformation att du vet att det är
rätt bil.
</p>
</div>
<div class="home__step">
<span class="home__step-number">2</span>
<h3>Skriv meddelandet</h3>
<p>
Utgå från en mall eller skriv själv. Du ser brevet innan du
skickar.
</p>
</div>
<div class="home__step">
<span class="home__step-number">3</span>
<h3>Vi postar brevet</h3>
<p>
Efter betalning hanteras brevet manuellt och skickas med post.
</p>
</div>
</div>
</div>
</section>
<section class="home__trust">
<div class="home__trust-inner">
<p class="home__trust-text">
<strong>Trygg hantering.</strong> Bilhej hanterar adressuppgifter
endast för att kunna posta brevet. Mottagarens adress visas inte i
tjänsten.
</p>
</div>
</section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.home { .home__hero {
max-width: 28rem; display: grid;
margin: 3rem auto 0; grid-template-columns: 1fr 1fr;
padding: 0 1rem; gap: var(--space-3xl);
align-items: center;
max-width: 72rem;
margin: 0 auto;
padding: var(--space-3xl) var(--space-lg);
} }
.home__subtitle { .home__eyebrow {
color: #718096; display: inline-block;
margin: 0 0 1.5rem 0; font-size: 0.875rem;
font-weight: 500;
color: var(--color-primary);
margin: 0 0 var(--space-md) 0;
}
.home__headline {
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
line-height: 1.15;
color: var(--color-ink);
margin: 0 0 var(--space-lg) 0;
}
.home__lead {
font-size: 1.0625rem;
color: var(--color-muted);
line-height: 1.7;
margin: 0;
max-width: 30rem;
}
.home__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-lg);
} }
.home__cta { .home__cta {
display: block; display: flex;
margin-top: 1.5rem; margin-top: var(--space-lg);
padding: 0.875rem 1.5rem;
background: #38a169;
color: #fff;
text-align: center;
text-decoration: none;
font-weight: 600;
border-radius: 0.5rem;
font-size: 1rem;
} }
.home__cta:hover { .home__uses {
background: #2f855a; padding: var(--space-3xl) var(--space-lg);
}
.home__uses-inner {
max-width: 72rem;
margin: 0 auto;
}
.home__uses-title {
font-size: 1.375rem;
font-weight: 700;
text-align: center;
margin: 0 0 var(--space-xl) 0;
color: var(--color-ink);
}
.home__uses-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
.home__use {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
box-shadow: var(--shadow-sm);
}
.home__use h3 {
margin: 0 0 var(--space-sm) 0;
font-size: 1rem;
color: var(--color-ink);
}
.home__use p {
margin: 0;
font-size: 0.875rem;
color: var(--color-muted);
line-height: 1.6;
}
.home__steps {
padding: var(--space-3xl) var(--space-lg);
}
.home__steps-inner {
max-width: 72rem;
margin: 0 auto;
}
.home__steps-title {
font-size: 1.375rem;
font-weight: 700;
text-align: center;
margin: 0 0 var(--space-xl) 0;
color: var(--color-ink);
}
.home__steps-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
.home__step {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
text-align: center;
box-shadow: var(--shadow-sm);
}
.home__step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 700;
font-size: 1rem;
border-radius: var(--radius-full);
margin-bottom: var(--space-md);
}
.home__step h3 {
margin: 0 0 var(--space-sm) 0;
font-size: 1rem;
color: var(--color-ink);
}
.home__step p {
margin: 0;
font-size: 0.875rem;
color: var(--color-muted);
line-height: 1.6;
}
.home__trust {
padding: 0 var(--space-lg) var(--space-3xl);
}
.home__trust-inner {
max-width: 48rem;
margin: 0 auto;
padding: var(--space-lg) var(--space-xl);
background: var(--color-primary-soft);
border-radius: var(--radius-lg);
text-align: center;
}
.home__trust-text {
margin: 0;
font-size: 0.9375rem;
color: var(--color-primary-dark);
line-height: 1.7;
}
@media (max-width: 768px) {
.home__hero {
grid-template-columns: 1fr;
gap: var(--space-xl);
padding: var(--space-xl) var(--space-lg);
}
.home__headline {
font-size: clamp(1.75rem, 6vw, 2.5rem);
}
.home__uses-grid {
grid-template-columns: 1fr;
}
.home__steps-grid {
grid-template-columns: 1fr;
}
} }
</style> </style>

View file

@ -35,153 +35,107 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<div class="login"> <div class="page">
<h1 class="login__title">Logga in</h1> <div class="page__card">
<p class="login__subtitle"> <h1 class="page__title">Logga in</h1>
Ange din e-postadress och ditt lösenord för att logga in. <p class="page__subtitle">
</p> Ange din e-postadress och ditt lösenord för att logga in.
</p>
<form class="login__form" @submit.prevent="handleSubmit"> <form
<div class="login__field"> class="page__form"
<label for="email" class="login__label">E-postadress</label> method="post"
<input action="/api/auth/login"
id="email" @submit.prevent="handleSubmit"
v-model="email"
type="email"
autocomplete="email"
class="login__input"
placeholder="namn@exempel.se"
/>
</div>
<div class="login__field">
<label for="password" class="login__label">Lösenord</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="login__input"
placeholder="Ditt lösenord"
/>
</div>
<p v-if="errorMessage" class="login__api-error">{{ errorMessage }}</p>
<button
type="submit"
class="login__submit"
:disabled="!isValid || submitting"
> >
{{ submitting ? 'Loggar in...' : 'Logga in' }} <div class="field">
</button> <label for="email" class="field__label">E-postadress</label>
</form> <input
id="email"
v-model="email"
type="email"
name="email"
autocomplete="email"
class="field__input"
placeholder="namn@exempel.se"
/>
</div>
<p class="login__register-link"> <div class="field">
Har du inget konto? <label for="password" class="field__label">Lösenord</label>
<RouterLink to="/registrera">Skapa konto</RouterLink> <input
</p> id="password"
v-model="password"
type="password"
name="password"
autocomplete="current-password"
class="field__input"
placeholder="Ditt lösenord"
/>
</div>
<div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn--primary btn--lg login__submit"
:disabled="!isValid || submitting"
>
{{ submitting ? 'Loggar in...' : 'Logga in' }}
</button>
</form>
<p class="page__footer-link">
Har du inget konto?
<RouterLink to="/registrera">Skapa konto</RouterLink>
</p>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.login { .page {
max-width: 28rem; max-width: 28rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.login__title { .page__card {
margin: 0 0 0.25rem 0; background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.login__subtitle { .page__subtitle {
margin: 0 0 1.5rem 0; margin: 0 0 var(--space-xl) 0;
color: #718096;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.login__form { .page__form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--space-md);
} }
.login__field { .page__footer-link {
display: flex; margin-top: var(--space-lg);
flex-direction: column; text-align: center;
gap: 0.375rem;
}
.login__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.login__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.login__input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.login__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.login__submit { .login__submit {
width: 100%; width: 100%;
padding: 0.875rem 1.5rem;
background: #4299e1;
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.login__submit:hover:not(:disabled) {
background: #3182ce;
}
.login__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login__register-link {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: #718096;
}
.login__register-link a {
color: #4299e1;
text-decoration: none;
}
.login__register-link a:hover {
text-decoration: underline;
} }
</style> </style>

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { fetchOrders, type Order } from '@/api/orders' import { fetchOrders, type Order } from '@/api/orders'
import { RouterLink } from 'vue-router'
const orders = ref<Order[]>([]) const orders = ref<Order[]>([])
const loading = ref(true) const loading = ref(true)
@ -15,13 +16,13 @@ const statusLabels: Record<string, string> = {
failed: 'Misslyckad', failed: 'Misslyckad',
} }
const statusClasses: Record<string, string> = { const statusBadge: Record<string, string> = {
pending_payment: 'badge--gray', pending_payment: 'badge--muted',
paid: 'badge--blue', paid: 'badge--primary',
lookup_started: 'badge--blue', lookup_started: 'badge--primary',
sent: 'badge--green', sent: 'badge--success',
delivered: 'badge--green', delivered: 'badge--success',
failed: 'badge--red', failed: 'badge--danger',
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
@ -44,47 +45,63 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="orders"> <div class="page">
<h1 class="orders__title">Mina beställningar</h1> <h1 class="page__title">Mina beställningar</h1>
<p class="orders__subtitle">Här kan du se dina tidigare beställningar.</p> <p class="page__subtitle">Här kan du se dina tidigare beställningar.</p>
<p v-if="loading" class="orders__loading">Laddar beställningar...</p> <p
v-if="loading"
<p v-else-if="error" class="orders__error">{{ error }}</p> class="text-muted text-center orders__loading"
role="status"
<p v-else-if="orders.length === 0" class="orders__empty"> >
Du har inga beställningar ännu. Laddar beställningar...
</p> </p>
<div v-else-if="error" class="message message--error" role="alert">
{{ error }}
</div>
<div v-else-if="orders.length === 0" class="orders__empty">
<div class="orders__empty-card">
<p class="orders__empty-title">Inga beställningar ännu</p>
<p class="orders__empty-text">
Följ dina brev och se tidigare skickade hälsningar.
</p>
<RouterLink to="/" class="btn btn--primary orders__empty-cta">
Skicka första brevet
</RouterLink>
</div>
</div>
<div v-else class="orders__list"> <div v-else class="orders__list">
<div v-for="order in orders" :key="order.id" class="orders__card"> <div v-for="order in orders" :key="order.id" class="orders__card">
<div class="orders__card-header"> <div class="orders__card-top">
<span class="orders__plate">{{ order.plate }}</span> <span class="orders__plate">{{ order.plate }}</span>
<span <span
class="orders__badge" class="badge"
:class="statusClasses[order.status] || 'badge--gray'" :class="statusBadge[order.status] || 'badge--muted'"
> >
{{ statusLabels[order.status] || order.status }} {{ statusLabels[order.status] || order.status }}
</span> </span>
</div> </div>
<div class="orders__card-body"> <div class="orders__card-meta">
<div class="orders__detail"> <span class="orders__meta-label">Datum</span>
<span class="orders__label">Datum</span> <span class="orders__meta-value">{{
<span class="orders__value">{{ formatDate(order.createdAt) }}</span> formatDate(order.createdAt)
</div> }}</span>
<div v-if="order.trackingId" class="orders__detail"> <template v-if="order.trackingId">
<span class="orders__label">Spårning</span> <span class="orders__meta-label">Spårning</span>
<a <a
class="orders__tracking-link" class="orders__tracking"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`" :href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{{ order.trackingId }} {{ order.trackingId }}
</a> </a>
</div> </template>
</div> </div>
</div> </div>
</div> </div>
@ -92,140 +109,117 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
.orders { .page {
max-width: 48rem; max-width: 48rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.orders__title { .page__title {
margin: 0 0 0.25rem 0; margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.orders__subtitle { .page__subtitle {
margin: 0 0 1.5rem 0; margin: 0 0 var(--space-xl) 0;
color: #718096;
font-size: 0.875rem; font-size: 0.875rem;
} color: var(--color-muted);
.orders__loading,
.orders__error,
.orders__empty {
margin: 2rem 0;
padding: 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
text-align: center;
}
.orders__loading {
color: #718096;
}
.orders__error {
background: #fff5f5;
border: 1px solid #fed7d7;
color: #c53030;
}
.orders__empty {
background: #f7fafc;
border: 1px solid #e2e8f0;
color: #718096;
} }
.orders__list { .orders__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--space-md);
} }
.orders__card { .orders__card {
background: #fff; background: var(--color-surface);
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
border-radius: 0.75rem; border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-card);
} }
.orders__card-header { .orders__card-top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 1.25rem; padding: var(--space-md) var(--space-lg);
background: #f7fafc; background: var(--color-border-light);
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid var(--color-border);
} }
.orders__plate { .orders__plate {
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 600; font-weight: 600;
color: #1a202c; color: var(--color-ink);
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.orders__badge { .orders__card-meta {
display: inline-block; padding: var(--space-md) var(--space-lg);
padding: 0.25rem 0.75rem; display: grid;
border-radius: 9999px; grid-template-columns: auto 1fr;
font-size: 0.75rem; gap: var(--space-sm) var(--space-lg);
font-weight: 600; align-items: center;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.badge--gray { .orders__meta-label {
background: #edf2f7;
color: #718096;
}
.badge--blue {
background: #ebf8ff;
color: #2b6cb0;
}
.badge--green {
background: #f0fff4;
color: #276749;
}
.badge--red {
background: #fff5f5;
color: #c53030;
}
.orders__card-body {
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.orders__detail {
display: flex;
gap: 0.75rem;
}
.orders__label {
min-width: 5rem;
font-size: 0.8125rem; font-size: 0.8125rem;
color: #a0aec0; color: var(--color-soft);
font-weight: 500;
white-space: nowrap;
}
.orders__meta-value {
font-size: 0.875rem;
color: var(--color-ink);
}
.orders__tracking {
font-size: 0.875rem;
color: var(--color-primary);
text-decoration: none;
font-weight: 500; font-weight: 500;
} }
.orders__value { .orders__tracking:hover {
font-size: 0.875rem;
color: #4a5568;
}
.orders__tracking-link {
font-size: 0.875rem;
color: #4299e1;
text-decoration: none;
}
.orders__tracking-link:hover {
text-decoration: underline; text-decoration: underline;
} }
.orders__empty {
padding: var(--space-2xl) 0;
text-align: center;
}
.orders__empty-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
box-shadow: var(--shadow-card);
}
.orders__empty-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-ink);
margin: 0 0 var(--space-sm) 0;
}
.orders__empty-text {
font-size: 0.875rem;
color: var(--color-muted);
margin: 0;
}
.orders__empty-cta {
margin-top: var(--space-md);
display: inline-flex;
}
.orders__loading {
padding: var(--space-2xl) 0;
}
</style> </style>

View file

@ -26,113 +26,101 @@ async function handlePay() {
</script> </script>
<template> <template>
<div class="payment"> <div class="page">
<h1 class="payment__title">Betalning</h1> <div class="page__card">
<p class="payment__subtitle"> <h1 class="page__title">Betalning</h1>
Registreringsnummer: <strong>{{ route.query.plate || '—' }}</strong> <p class="page__plate">
</p> Registreringsnummer: <strong>{{ route.query.plate || '—' }}</strong>
</p>
<div class="payment__card"> <div class="payment__summary">
<div class="payment__amount-row"> <div class="payment__row">
<span class="payment__label">Att betala</span> <span class="payment__label">Att betala</span>
<span class="payment__amount">49 kr</span> <span class="payment__amount">49 kr</span>
</div>
</div> </div>
<p v-if="error" class="payment__error">{{ error }}</p> <div
v-if="error"
class="message message--error"
style="margin-bottom: var(--space-md)"
>
{{ error }}
</div>
<button class="payment__button" :disabled="paying" @click="handlePay"> <button
{{ paying ? 'Bearbetar...' : 'Betalt' }} class="btn btn--primary btn--lg payment__submit"
:disabled="paying"
@click="handlePay"
>
{{ paying ? 'Bearbetar...' : 'Genomför testbetalning' }}
</button> </button>
<p class="payment__note"> <p class="payment__note">
Detta är en mock-betalning. I framtiden skickas du till Stripe. Detta är en testbetalning i utvecklingsmiljön.
</p> </p>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.payment { .page {
max-width: 28rem; max-width: 28rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.payment__title { .page__card {
margin: 0 0 0.25rem 0; background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.payment__subtitle { .page__plate {
margin: 0 0 1.5rem 0; margin: 0 0 var(--space-xl) 0;
color: #718096;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.payment__card { .payment__summary {
background: #fff; margin-bottom: var(--space-lg);
border: 1px solid #e2e8f0; padding-bottom: var(--space-lg);
border-radius: 0.75rem; border-bottom: 1px solid var(--color-border);
padding: 1.5rem;
} }
.payment__amount-row { .payment__row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1.25rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid #e2e8f0;
} }
.payment__label { .payment__label {
font-size: 0.875rem; font-size: 0.875rem;
color: #718096; color: var(--color-muted);
} }
.payment__amount { .payment__amount {
font-size: 1.25rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
color: #1a202c; color: var(--color-ink);
} }
.payment__error { .payment__submit {
margin: 0 0 0.75rem 0;
padding: 0.5rem 0.75rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.375rem;
color: #c53030;
font-size: 0.8125rem;
}
.payment__button {
width: 100%; width: 100%;
padding: 0.75rem;
border: none;
border-radius: 0.5rem;
background: #48bb78;
color: #fff;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.payment__button:hover:not(:disabled) {
background: #38a169;
}
.payment__button:disabled {
opacity: 0.6;
cursor: not-allowed;
} }
.payment__note { .payment__note {
margin: 0.75rem 0 0 0; margin: var(--space-md) 0 0 0;
color: #a0aec0;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-soft);
text-align: center; text-align: center;
} }
</style> </style>

View file

@ -70,193 +70,139 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<div class="register"> <div class="page">
<h1 class="register__title">Skapa konto</h1> <div class="page__card">
<p class="register__subtitle"> <h1 class="page__title">Skapa konto</h1>
Ange din e-postadress och ett lösenord för att skapa ett konto. <p class="page__subtitle">
</p> Ange din e-postadress och ett lösenord för att skapa ett konto.
</p>
<form class="register__form" @submit.prevent="handleSubmit"> <form class="page__form" @submit.prevent="handleSubmit">
<div class="register__field"> <div class="field">
<label for="email" class="register__label">E-postadress</label> <label for="email" class="field__label">E-postadress</label>
<input <input
id="email" id="email"
v-model="email" v-model="email"
type="email" type="email"
autocomplete="email" autocomplete="email"
class="register__input" class="field__input"
:class="{ 'register__input--error': emailError }" :class="{ 'field__input--error': emailError }"
placeholder="namn@exempel.se" :aria-invalid="!!emailError"
@input="touched = true" aria-describedby="email-error"
/> placeholder="namn@exempel.se"
<p v-if="emailError" class="register__error">{{ emailError }}</p> @input="touched = true"
</div> />
<p v-if="emailError" id="email-error" class="field__error">
{{ emailError }}
</p>
</div>
<div class="register__field"> <div class="field">
<label for="password" class="register__label">Lösenord</label> <label for="password" class="field__label">Lösenord</label>
<input <input
id="password" id="password"
v-model="password" v-model="password"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
class="register__input" class="field__input"
:class="{ 'register__input--error': passwordError }" :class="{ 'field__input--error': passwordError }"
placeholder="Minst 8 tecken" :aria-invalid="!!passwordError"
@input="touched = true" aria-describedby="password-error"
/> placeholder="Minst 8 tecken"
<p v-if="passwordError" class="register__error">{{ passwordError }}</p> @input="touched = true"
</div> />
<p v-if="passwordError" id="password-error" class="field__error">
{{ passwordError }}
</p>
</div>
<div class="register__field"> <div class="field">
<label for="confirm-password" class="register__label" <label for="confirm-password" class="field__label"
>Bekräfta lösenord</label >Bekräfta lösenord</label
>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
class="field__input"
:class="{ 'field__input--error': confirmPasswordError }"
:aria-invalid="!!confirmPasswordError"
aria-describedby="confirm-password-error"
placeholder="Upprepa lösenord"
@input="touched = true"
/>
<p
v-if="confirmPasswordError"
id="confirm-password-error"
class="field__error"
>
{{ confirmPasswordError }}
</p>
</div>
<div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn--primary btn--lg register__submit"
:disabled="!isValid || submitting"
> >
<input {{ submitting ? 'Skapar konto...' : 'Skapa konto' }}
id="confirm-password" </button>
v-model="confirmPassword" </form>
type="password"
autocomplete="new-password"
class="register__input"
:class="{ 'register__input--error': confirmPasswordError }"
placeholder="Upprepa lösenord"
@input="touched = true"
/>
<p v-if="confirmPasswordError" class="register__error">
{{ confirmPasswordError }}
</p>
</div>
<p v-if="errorMessage" class="register__api-error">{{ errorMessage }}</p> <p class="page__footer-link">
Har du redan ett konto?
<button <RouterLink to="/logga-in">Logga in</RouterLink>
type="submit" </p>
class="register__submit" </div>
:disabled="!isValid || submitting"
>
{{ submitting ? 'Skapar konto...' : 'Skapa konto' }}
</button>
</form>
<p class="register__login-link">
Har du redan ett konto?
<RouterLink to="/logga-in">Logga in</RouterLink>
</p>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.register { .page {
max-width: 28rem; max-width: 28rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.register__title { .page__card {
margin: 0 0 0.25rem 0; background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.register__subtitle { .page__subtitle {
margin: 0 0 1.5rem 0; margin: 0 0 var(--space-xl) 0;
color: #718096;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.register__form { .page__form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--space-md);
} }
.register__field { .page__footer-link {
display: flex; margin-top: var(--space-lg);
flex-direction: column; text-align: center;
gap: 0.375rem;
}
.register__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.register__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.register__input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.register__input--error {
border-color: #e53e3e;
}
.register__input--error:focus {
border-color: #e53e3e;
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.25);
}
.register__error {
margin: 0;
font-size: 0.8125rem;
color: #e53e3e;
}
.register__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.register__submit { .register__submit {
width: 100%; width: 100%;
padding: 0.875rem 1.5rem;
background: #38a169;
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.register__submit:hover:not(:disabled) {
background: #2f855a;
}
.register__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.register__login-link {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: #718096;
}
.register__login-link a {
color: #4299e1;
text-decoration: none;
}
.register__login-link a:hover {
text-decoration: underline;
} }
</style> </style>