Compare commits
3 commits
8cd7991603
...
2506a0283c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2506a0283c | |||
| 851cd8afa0 | |||
| 00327674ed |
35 changed files with 2136 additions and 1202 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -14,6 +14,6 @@ import AppFooter from '@/components/AppFooter.vue'
|
|||
|
||||
<style>
|
||||
.app__main {
|
||||
min-height: calc(100vh - 12rem);
|
||||
min-height: calc(100vh - 10rem);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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('/')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"]')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ describe('TemplatePicker', () => {
|
|||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
expect(wrapper.emitted('select')![0][0]).toMatchObject({
|
||||
name: 'Komplimang',
|
||||
icon: '🌟',
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
409
frontend/src/assets/styles/base.css
Normal file
409
frontend/src/assets/styles/base.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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">
|
||||
© {{ 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')">×</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 på 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}) —
|
||||
{{ 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>
|
||||
|
|
|
|||
|
|
@ -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 så 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 på att sälja den, så 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 så 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 på en situation i trafiken där jag reagerade på 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 så ibland, men jag ville nå ut för att lösa det på 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: '',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="/">Gå 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="/">Gå 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>
|
||||
|
|
|
|||
|
|
@ -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 på
|
||||
<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 på
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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">Så 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 så 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue