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

View file

@ -44,7 +44,7 @@ test.describe('Compose flow', () => {
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()
})
@ -58,7 +58,7 @@ test.describe('Compose flow', () => {
await page.goto('/compose?plate=ABC123')
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 button.click()
@ -79,7 +79,7 @@ test.describe('Compose flow', () => {
await page.getByLabel('Ditt meddelande').fill('Testmeddelande')
await expect(
page.getByText('Detta brev skickades via BilHej.se'),
page.getByText('Detta brev skickades via Bilhej'),
).toBeVisible()
await expect(page.getByText('Transportstyrelsens fordonsregister')).toBeVisible()
})

View file

@ -98,6 +98,51 @@ test.describe('Header auth state', () => {
).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 {

View file

@ -45,4 +45,21 @@ test.describe('Login page', () => {
'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 }) => {
await page.goto('/compose?plate=ABC123')
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.getByRole('heading', { name: 'Betalning' })).toBeVisible()
@ -20,15 +20,15 @@ test.describe('Payment redirect', () => {
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,
}) => {
await page.goto('/compose?plate=DEF456')
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.getByRole('button', { name: 'Betalt' }).click()
await page.getByRole('button', { name: 'Genomför testbetalning' }).click()
await expect(page).toHaveURL('/orders')
await expect(page.getByText('DEF456').first()).toBeVisible()
@ -44,9 +44,9 @@ test.describe('Payment redirect', () => {
test('shows mock payment note', async ({ page }) => {
await page.goto('/compose?plate=GHI789')
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 expect(page.getByText(/mock-betalning/i)).toBeVisible()
await expect(page.locator('.payment__note')).toBeVisible()
})
})

View file

@ -2,9 +2,14 @@
<html lang="sv">
<head>
<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" />
<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>
<body>
<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>
.app__main {
min-height: calc(100vh - 12rem);
min-height: calc(100vh - 10rem);
}
</style>

View file

@ -5,6 +5,6 @@ import AboutPage from '@/pages/AboutPage.vue'
describe('AboutPage', () => {
it('renders heading', () => {
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()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Administration')
expect(wrapper.text()).toContain(
'Hantera beställningar, mallar och användare',
)
})
it('shows loading state initially', async () => {
@ -124,30 +121,31 @@ describe('AdminDashboard', () => {
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()
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)
await rows[0].trigger('click')
const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hej fin bil!')
expect(wrapper.text()).toContain('Brevtext')
})
it('collapses row on second click', async () => {
it('collapses row on second button click', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[0].trigger('click')
const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hej fin bil!')
await rows[0].trigger('click')
await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Hej fin bil!')
})
@ -156,12 +154,12 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[0].trigger('click')
const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hej fin bil!')
await rows[1].trigger('click')
await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Hej fin bil!')
expect(wrapper.text()).toContain('Vill köpa din bil.')
@ -171,7 +169,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage()
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)
})
@ -185,7 +183,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage()
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 new Promise((r) => setTimeout(r, 50))
@ -208,7 +206,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage()
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 new Promise((r) => setTimeout(r, 50))
@ -226,24 +224,24 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[0].trigger('click')
const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.find('.admin-dashboard__tracking').exists()).toBe(true)
expect(wrapper.find('.admin-dashboard__tracking-input').exists()).toBe(true)
expect(wrapper.find('.admin-dashboard__tracking-save').exists()).toBe(true)
expect(wrapper.find('.admin__tracking-row').exists()).toBe(true)
expect(wrapper.find('.admin__tracking-input').exists()).toBe(true)
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
it('shows tracking link when trackingId is set', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[0].trigger('click')
const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[0].trigger('click')
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.attributes('href')).toContain('postnord')
expect(link.attributes('target')).toBe('_blank')
@ -253,11 +251,11 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[1].trigger('click')
const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[1].trigger('click')
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)
})
@ -269,11 +267,11 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[1].trigger('click')
const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[1].trigger('click')
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))
expect(globalThis.fetch).toHaveBeenCalledWith(
@ -294,11 +292,11 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[1].trigger('click')
const expandBtns = wrapper.findAll('.admin__expand-btn')
await expandBtns[1].trigger('click')
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))
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID')

View file

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

View file

@ -90,7 +90,7 @@ describe('ComposePage', () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
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')
})
@ -174,7 +174,7 @@ describe('ComposePage', () => {
it('shows GDPR footer in preview', async () => {
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 () => {

View file

@ -21,16 +21,16 @@ function mountHome(router: ReturnType<typeof createTestRouter>) {
}
describe('HomePage', () => {
it('renders subtitle', () => {
it('renders headline', () => {
const router = createTestRouter()
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', () => {
const router = createTestRouter()
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 () => {
@ -41,7 +41,7 @@ describe('HomePage', () => {
await plateInput.vm.$emit('lookup', 'ABC123')
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 () => {
@ -52,7 +52,7 @@ describe('HomePage', () => {
await plateInput.vm.$emit('lookup', 'UNKNOWN')
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 () => {
@ -64,9 +64,9 @@ describe('HomePage', () => {
await plateInput.vm.$emit('lookup', 'ABC123')
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.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 () => {
@ -78,7 +78,7 @@ describe('HomePage', () => {
await plateInput.vm.$emit('lookup', 'ABC123')
await new Promise((resolve) => setTimeout(resolve, 500))
const cta = wrapper.find('.home__cta')
const cta = wrapper.find('.btn--primary')
const href = cta.attributes('href')
expect(href).toBe('/compose?plate=ABC123')
})

View file

@ -70,6 +70,23 @@ describe('LoginPage', () => {
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 () => {
const { wrapper } = mountPage()
const button = wrapper.find('button[type="submit"]')

View file

@ -136,7 +136,7 @@ describe('OrdersPage', () => {
vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
const { wrapper } = mountPage()
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 () => {
@ -151,8 +151,8 @@ describe('OrdersPage', () => {
it('applies correct badge class for status', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const badges = wrapper.findAll('.orders__badge')
expect(badges[0].classes()).toContain('badge--green')
expect(badges[1].classes()).toContain('badge--gray')
const badges = wrapper.findAll('.badge')
expect(badges[0].classes()).toContain('badge--success')
expect(badges[1].classes()).toContain('badge--muted')
})
})

View file

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

View file

@ -24,7 +24,6 @@ describe('TemplatePicker', () => {
expect(wrapper.emitted('select')).toHaveLength(1)
expect(wrapper.emitted('select')![0][0]).toMatchObject({
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>
<footer class="app-footer">
<nav class="app-footer__links">
<RouterLink to="/om" class="app-footer__link">Om oss</RouterLink>
<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>
<div class="app-footer__inner">
<p class="app-footer__tagline">
Bilhej hjälper dig att skicka brev till bilägare via
registreringsnummer.
</p>
<nav class="app-footer__links">
<RouterLink to="/om" class="app-footer__link">Om oss</RouterLink>
<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>
</template>
<style scoped>
.app-footer {
background: #f7fafc;
border-top: 1px solid #e2e8f0;
padding: 1.5rem;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
.app-footer__inner {
max-width: 72rem;
margin: 0 auto;
padding: var(--space-xl) var(--space-lg);
text-align: center;
}
.app-footer__tagline {
color: var(--color-muted);
font-size: 0.875rem;
margin: 0 0 var(--space-lg) 0;
}
.app-footer__links {
display: flex;
justify-content: center;
gap: 2rem;
gap: var(--space-xl);
flex-wrap: wrap;
margin-bottom: var(--space-lg);
}
.app-footer__link {
color: #718096;
color: var(--color-soft);
text-decoration: none;
font-size: 0.8125rem;
transition: color var(--transition-fast);
}
.app-footer__link:hover {
color: #1a202c;
color: var(--color-ink);
}
.app-footer__copy {
color: var(--color-soft);
font-size: 0.75rem;
margin: 0;
}
</style>

View file

@ -1,83 +1,170 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const auth = useAuthStore()
function handleLogout() {
auth.logout()
router.push('/')
}
</script>
<template>
<header class="app-header">
<RouterLink to="/" class="app-header__logo">BilHälsning</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
<div class="app-header__inner">
<RouterLink to="/" class="app-header__logo">
<svg
class="app-header__logo-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<RouterLink to="/registrera" class="app-header__link"
>Registrera</RouterLink
>
</template>
<template v-else>
<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="auth.logout()">
Logga ut
</button>
</template>
</nav>
<rect
x="2"
y="5"
width="20"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M2 7l10 6 10-6"
stroke="currentColor"
stroke-width="2"
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>
</template>
<style scoped>
.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;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
background: #fff;
max-width: 72rem;
margin: 0 auto;
padding: 0.875rem var(--space-lg);
}
.app-header__logo {
font-size: 1.25rem;
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 1.125rem;
font-weight: 700;
color: #1a202c;
color: var(--color-ink);
text-decoration: none;
}
.app-header__logo-icon {
width: 1.5rem;
height: 1.5rem;
}
.app-header__nav {
display: flex;
gap: 1rem;
align-items: center;
gap: var(--space-sm);
}
.app-header__link {
color: #4a5568;
text-decoration: none;
padding: 0.4rem 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 {
color: #1a202c;
.app-header__link:hover,
.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 {
color: #4a5568;
font-size: 0.875rem;
color: var(--color-muted);
font-size: 0.8125rem;
padding: 0 0.5rem;
}
.app-header__logout {
background: none;
border: none;
color: #4a5568;
font-size: 0.875rem;
border: 1px solid var(--color-border);
color: var(--color-muted);
font-size: 0.8125rem;
font-weight: 500;
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 {
color: #1a202c;
color: var(--color-danger);
border-color: var(--color-danger);
background: var(--color-danger-soft);
}
</style>

View file

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

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { templates, type LetterTemplate } from '@/data/templates'
const emit = defineEmits<{
@ -6,21 +7,59 @@ const emit = defineEmits<{
(e: 'close'): void
}>()
const dialogRef = ref<HTMLDivElement | null>(null)
function handleSelect(template: LetterTemplate) {
emit('select', template)
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>
<template>
<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">
<h2 class="modal__title">Välj en mall</h2>
<button class="modal__close" @click="emit('close')">&times;</button>
<h2 id="modal-title" class="modal__title">Välj en mall</h2>
<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>
<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>
<div class="modal__grid">
<button
@ -29,7 +68,6 @@ function handleSelect(template: LetterTemplate) {
class="modal__card"
@click="handleSelect(t)"
>
<span class="modal__card-icon">{{ t.icon }}</span>
<span class="modal__card-name">{{ t.name }}</span>
</button>
</div>
@ -46,84 +84,88 @@ function handleSelect(template: LetterTemplate) {
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
padding: var(--space-md);
}
.modal {
background: #fff;
border-radius: 1rem;
background: var(--color-surface);
border-radius: var(--radius-xl);
width: 100%;
max-width: 28rem;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-xl);
outline: none;
}
.modal__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1.5rem 0;
padding: var(--space-lg) var(--space-lg) 0;
}
.modal__title {
margin: 0;
font-size: 1.25rem;
color: #1a202c;
color: var(--color-ink);
}
.modal__close {
background: none;
border: none;
font-size: 1.5rem;
color: #a0aec0;
color: var(--color-soft);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
padding: 0.25rem;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 0.15s,
background 0.15s;
color var(--transition-fast),
background var(--transition-fast);
}
.modal__close:hover {
color: #4a5568;
background: #f7fafc;
color: var(--color-ink);
background: var(--color-border-light);
}
.modal__subtitle {
margin: 0.5rem 0 0;
padding: 0 1.5rem;
margin: var(--space-sm) 0 0;
padding: 0 var(--space-lg);
font-size: 0.875rem;
color: #718096;
color: var(--color-muted);
}
.modal__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding: 1.25rem 1.5rem 1.5rem;
gap: var(--space-sm);
padding: var(--space-lg);
}
.modal__card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem 0.75rem;
background: #f7fafc;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
gap: var(--space-sm);
padding: var(--space-lg) var(--space-sm);
background: var(--color-primary-soft);
border: 2px solid transparent;
border-radius: var(--radius-lg);
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
border-color var(--transition-fast),
background var(--transition-fast),
transform 0.1s;
font-family: inherit;
}
.modal__card:hover {
border-color: #4299e1;
background: #ebf8ff;
border-color: var(--color-primary);
background: #dbeafe;
transform: translateY(-1px);
}
@ -131,14 +173,10 @@ function handleSelect(template: LetterTemplate) {
transform: translateY(0);
}
.modal__card-icon {
font-size: 2rem;
}
.modal__card-name {
font-size: 0.8125rem;
font-weight: 600;
color: #4a5568;
color: var(--color-ink);
text-align: center;
line-height: 1.3;
}

View file

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

View file

@ -1,13 +1,11 @@
export interface LetterTemplate {
name: string
icon: string
body: string
}
export const templates: LetterTemplate[] = [
{
name: 'Komplimang',
icon: '🌟',
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.
@ -16,7 +14,6 @@ Ha en trevlig dag!`,
},
{
name: 'Köpförfrågan',
icon: '🚗',
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.
@ -28,7 +25,6 @@ Vänliga hälsningar,
},
{
name: 'Tips / servicebehov',
icon: '🔧',
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.
@ -37,7 +33,6 @@ Hoppas detta var till hjälp!`,
},
{
name: 'Körbeteende',
icon: '🛣️',
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.
@ -46,7 +41,6 @@ Tack för att du lyssnar!`,
},
{
name: 'Tuta / frustration',
icon: '📢',
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.
@ -55,7 +49,6 @@ Ha det bra!`,
},
{
name: 'Mindre parkeringsskada',
icon: '🅿️',
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.
@ -67,7 +60,6 @@ Vänliga hälsningar,
},
{
name: 'Fritt meddelande',
icon: '✏️',
body: '',
},
]

View file

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

View file

@ -1,19 +1,39 @@
<script setup lang="ts"></script>
<template>
<div class="about">
<h1>Om BilHälsning</h1>
<p>
BilHälsning är en tjänst som låter dig skicka fysiska brev till
fordonsägare via registreringsnummer.
</p>
<div class="page">
<div class="page__card">
<h1>Om Bilhej</h1>
<p>
Bilhej är en tjänst som låter dig skicka fysiska brev till fordonsägare
via registreringsnummer.
</p>
</div>
</div>
</template>
<style scoped>
.about {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
.page {
max-width: 36rem;
margin: var(--space-3xl) auto 0;
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>

View file

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

View file

@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders'
import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue'
import { RouterLink } from 'vue-router'
const router = useRouter()
const route = useRoute()
@ -21,7 +22,7 @@ const canSubmit = computed(
)
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) {
letterText.value = template.body
@ -50,61 +51,75 @@ async function handleSubmit() {
<template>
<div class="compose">
<h1 class="compose__title">Skriv ditt brev</h1>
<p v-if="plate" class="compose__plate">
Registreringsnummer: <strong>{{ plate }}</strong>
</p>
<p v-if="!plate" class="compose__error">
Inget registreringsnummer valt.
<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
<div v-if="plate" class="compose__layout">
<div class="compose__editor">
<h1 class="compose__title">Skriv ditt brev</h1>
<p class="compose__plate-badge">
<span class="compose__plate-label">Regnr</span>
<span class="compose__plate-value">{{ plate }}</span>
</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 class="compose__preview">
<h2 class="compose__preview-title">Förhandsvisning</h2>
<div class="compose__preview-page">
<p class="compose__preview-plate">Registreringsnummer: {{ plate }}</p>
<p class="compose__preview-body" style="white-space: pre-wrap">
<p class="compose__preview-plate-label">
Registreringsnummer: {{ plate }}
</p>
<p class="compose__preview-body">
{{ letterText }}
</p>
<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>
<p v-if="errorMessage" class="compose__api-error">{{ errorMessage }}</p>
<button type="submit" class="compose__submit" :disabled="!canSubmit">
{{ submitting ? 'Skickar...' : 'Skicka brev (49 kr)' }}
</button>
</form>
<div v-else class="message message--error compose__error">
Inget registreringsnummer valt.
<RouterLink to="/"> tillbaka</RouterLink>
</div>
<TemplatePicker
v-if="showPicker"
@ -115,59 +130,50 @@ async function handleSubmit() {
</template>
<style scoped>
.compose {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
.compose__layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xl);
align-items: start;
max-width: 56rem;
margin: var(--space-2xl) auto 0;
padding: 0 var(--space-lg);
}
.compose__title {
margin: 0 0 0.25rem 0;
margin: 0 0 var(--space-lg) 0;
font-size: 1.5rem;
color: #1a202c;
color: var(--color-ink);
}
.compose__plate {
margin: 0 0 1.5rem 0;
color: #4a5568;
font-size: 0.875rem;
.compose__plate-badge {
display: inline-flex;
align-items: center;
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 {
margin: 2rem 0;
padding: 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
.compose__plate-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-muted);
}
.compose__error a {
color: #4299e1;
text-decoration: none;
}
.compose__error a:hover {
text-decoration: underline;
.compose__plate-value {
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-primary-dark);
}
.compose__form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.compose__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.compose__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
gap: var(--space-md);
}
.compose__label-row {
@ -177,129 +183,112 @@ async function handleSubmit() {
}
.compose__templates-btn {
background: #ebf8ff;
border: 1px solid #bee3f8;
color: #2b6cb0;
font-size: 0.8125rem;
background: var(--color-primary-soft);
border: 1px solid #ddd6fe;
color: var(--color-primary-dark);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
padding: 0.3rem 0.75rem;
border-radius: var(--radius-full);
transition:
background 0.15s,
border-color 0.15s;
background var(--transition-fast),
border-color var(--transition-fast);
}
.compose__templates-btn:hover {
background: #bee3f8;
border-color: #90cdf4;
background: #e9d5ff;
border-color: #c4b5fd;
}
.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;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.compose__textarea:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
min-height: 10rem;
font-family: inherit;
}
.compose__counter {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
text-align: right;
}
.compose__counter--warn {
color: #e53e3e;
}
.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;
color: var(--color-danger) !important;
}
.compose__submit {
width: 100%;
padding: 0.875rem 1.5rem;
background: #38a169;
color: #fff;
border: none;
border-radius: 0.5rem;
}
.compose__error {
max-width: 28rem;
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-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
color: var(--color-muted);
}
.compose__submit:hover:not(:disabled) {
background: #2f855a;
.compose__preview-page {
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 {
opacity: 0.5;
cursor: not-allowed;
.compose__preview-plate-label {
margin: 0 0 var(--space-lg) 0;
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>

View file

@ -1,19 +1,39 @@
<script setup lang="ts"></script>
<template>
<div class="contact">
<h1>Kontakta oss</h1>
<p>
Har du frågor eller feedback? Hör av dig till oss
<a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>.
</p>
<div class="page">
<div class="page__card">
<h1>Kontakta oss</h1>
<p>
Har du frågor eller feedback? Hör av dig till oss
<a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>.
</p>
</div>
</div>
</template>
<style scoped>
.contact {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
.page {
max-width: 36rem;
margin: var(--space-3xl) auto 0;
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>

View file

@ -37,53 +37,289 @@ function handleLookup(lookedUpPlate: string) {
<template>
<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
:vehicle="vehicle"
:loading="lookingUp"
:not-found="notFound"
:plate="plate"
/>
<VehicleInfo
:vehicle="vehicle"
:loading="lookingUp"
:not-found="notFound"
:plate="plate"
/>
<RouterLink
v-if="vehicle"
:to="{ name: 'compose', query: { plate } }"
class="home__cta"
>
Skicka ett brev till ägaren
</RouterLink>
<RouterLink
v-if="vehicle"
:to="{ name: 'compose', query: { plate } }"
class="btn btn--primary btn--lg home__cta"
>
Fortsätt till brevet
</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>
</template>
<style scoped>
.home {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
.home__hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3xl);
align-items: center;
max-width: 72rem;
margin: 0 auto;
padding: var(--space-3xl) var(--space-lg);
}
.home__subtitle {
color: #718096;
margin: 0 0 1.5rem 0;
.home__eyebrow {
display: inline-block;
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 {
display: block;
margin-top: 1.5rem;
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;
display: flex;
margin-top: var(--space-lg);
}
.home__cta:hover {
background: #2f855a;
.home__uses {
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>

View file

@ -35,153 +35,107 @@ async function handleSubmit() {
</script>
<template>
<div class="login">
<h1 class="login__title">Logga in</h1>
<p class="login__subtitle">
Ange din e-postadress och ditt lösenord för att logga in.
</p>
<div class="page">
<div class="page__card">
<h1 class="page__title">Logga in</h1>
<p class="page__subtitle">
Ange din e-postadress och ditt lösenord för att logga in.
</p>
<form class="login__form" @submit.prevent="handleSubmit">
<div class="login__field">
<label for="email" class="login__label">E-postadress</label>
<input
id="email"
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"
<form
class="page__form"
method="post"
action="/api/auth/login"
@submit.prevent="handleSubmit"
>
{{ submitting ? 'Loggar in...' : 'Logga in' }}
</button>
</form>
<div class="field">
<label for="email" class="field__label">E-postadress</label>
<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">
Har du inget konto?
<RouterLink to="/registrera">Skapa konto</RouterLink>
</p>
<div class="field">
<label for="password" class="field__label">Lösenord</label>
<input
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>
</template>
<style scoped>
.login {
.page {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
}
.login__title {
margin: 0 0 0.25rem 0;
.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);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem;
color: #1a202c;
color: var(--color-ink);
}
.login__subtitle {
margin: 0 0 1.5rem 0;
color: #718096;
.page__subtitle {
margin: 0 0 var(--space-xl) 0;
font-size: 0.875rem;
color: var(--color-muted);
}
.login__form {
.page__form {
display: flex;
flex-direction: column;
gap: 1rem;
gap: var(--space-md);
}
.login__field {
display: flex;
flex-direction: column;
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;
.page__footer-link {
margin-top: var(--space-lg);
text-align: center;
font-size: 0.875rem;
color: var(--color-muted);
}
.login__submit {
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>

View file

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

View file

@ -26,113 +26,101 @@ async function handlePay() {
</script>
<template>
<div class="payment">
<h1 class="payment__title">Betalning</h1>
<p class="payment__subtitle">
Registreringsnummer: <strong>{{ route.query.plate || '—' }}</strong>
</p>
<div class="page">
<div class="page__card">
<h1 class="page__title">Betalning</h1>
<p class="page__plate">
Registreringsnummer: <strong>{{ route.query.plate || '—' }}</strong>
</p>
<div class="payment__card">
<div class="payment__amount-row">
<span class="payment__label">Att betala</span>
<span class="payment__amount">49 kr</span>
<div class="payment__summary">
<div class="payment__row">
<span class="payment__label">Att betala</span>
<span class="payment__amount">49 kr</span>
</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">
{{ paying ? 'Bearbetar...' : 'Betalt' }}
<button
class="btn btn--primary btn--lg payment__submit"
:disabled="paying"
@click="handlePay"
>
{{ paying ? 'Bearbetar...' : 'Genomför testbetalning' }}
</button>
<p class="payment__note">
Detta är en mock-betalning. I framtiden skickas du till Stripe.
Detta är en testbetalning i utvecklingsmiljön.
</p>
</div>
</div>
</template>
<style scoped>
.payment {
.page {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
}
.payment__title {
margin: 0 0 0.25rem 0;
.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);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem;
color: #1a202c;
color: var(--color-ink);
}
.payment__subtitle {
margin: 0 0 1.5rem 0;
color: #718096;
.page__plate {
margin: 0 0 var(--space-xl) 0;
font-size: 0.875rem;
color: var(--color-muted);
}
.payment__card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 1.5rem;
.payment__summary {
margin-bottom: var(--space-lg);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
.payment__amount-row {
.payment__row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid #e2e8f0;
}
.payment__label {
font-size: 0.875rem;
color: #718096;
color: var(--color-muted);
}
.payment__amount {
font-size: 1.25rem;
font-size: 1.5rem;
font-weight: 700;
color: #1a202c;
color: var(--color-ink);
}
.payment__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;
}
.payment__button {
.payment__submit {
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 {
margin: 0.75rem 0 0 0;
color: #a0aec0;
margin: var(--space-md) 0 0 0;
font-size: 0.75rem;
color: var(--color-soft);
text-align: center;
}
</style>

View file

@ -70,193 +70,139 @@ async function handleSubmit() {
</script>
<template>
<div class="register">
<h1 class="register__title">Skapa konto</h1>
<p class="register__subtitle">
Ange din e-postadress och ett lösenord för att skapa ett konto.
</p>
<div class="page">
<div class="page__card">
<h1 class="page__title">Skapa konto</h1>
<p class="page__subtitle">
Ange din e-postadress och ett lösenord för att skapa ett konto.
</p>
<form class="register__form" @submit.prevent="handleSubmit">
<div class="register__field">
<label for="email" class="register__label">E-postadress</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
class="register__input"
:class="{ 'register__input--error': emailError }"
placeholder="namn@exempel.se"
@input="touched = true"
/>
<p v-if="emailError" class="register__error">{{ emailError }}</p>
</div>
<form class="page__form" @submit.prevent="handleSubmit">
<div class="field">
<label for="email" class="field__label">E-postadress</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
class="field__input"
:class="{ 'field__input--error': emailError }"
:aria-invalid="!!emailError"
aria-describedby="email-error"
placeholder="namn@exempel.se"
@input="touched = true"
/>
<p v-if="emailError" id="email-error" class="field__error">
{{ emailError }}
</p>
</div>
<div class="register__field">
<label for="password" class="register__label">Lösenord</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="new-password"
class="register__input"
:class="{ 'register__input--error': passwordError }"
placeholder="Minst 8 tecken"
@input="touched = true"
/>
<p v-if="passwordError" class="register__error">{{ passwordError }}</p>
</div>
<div class="field">
<label for="password" class="field__label">Lösenord</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="new-password"
class="field__input"
:class="{ 'field__input--error': passwordError }"
:aria-invalid="!!passwordError"
aria-describedby="password-error"
placeholder="Minst 8 tecken"
@input="touched = true"
/>
<p v-if="passwordError" id="password-error" class="field__error">
{{ passwordError }}
</p>
</div>
<div class="register__field">
<label for="confirm-password" class="register__label"
>Bekräfta lösenord</label
<div class="field">
<label for="confirm-password" class="field__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
id="confirm-password"
v-model="confirmPassword"
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>
{{ submitting ? 'Skapar konto...' : 'Skapa konto' }}
</button>
</form>
<p v-if="errorMessage" class="register__api-error">{{ errorMessage }}</p>
<button
type="submit"
class="register__submit"
: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>
<p class="page__footer-link">
Har du redan ett konto?
<RouterLink to="/logga-in">Logga in</RouterLink>
</p>
</div>
</div>
</template>
<style scoped>
.register {
.page {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
}
.register__title {
margin: 0 0 0.25rem 0;
.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);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem;
color: #1a202c;
color: var(--color-ink);
}
.register__subtitle {
margin: 0 0 1.5rem 0;
color: #718096;
.page__subtitle {
margin: 0 0 var(--space-xl) 0;
font-size: 0.875rem;
color: var(--color-muted);
}
.register__form {
.page__form {
display: flex;
flex-direction: column;
gap: 1rem;
gap: var(--space-md);
}
.register__field {
display: flex;
flex-direction: column;
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;
.page__footer-link {
margin-top: var(--space-lg);
text-align: center;
font-size: 0.875rem;
color: var(--color-muted);
}
.register__submit {
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>