diff --git a/frontend/index.html b/frontend/index.html
index d4c6e54..a4f349f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,9 +2,14 @@
-
+
- BilHälsning
+
+
+
+
+
+ Bilhej — Skicka brev till fordonsägare
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
index 6893eb1..a1d1a0a 100644
--- a/frontend/public/favicon.svg
+++ b/frontend/public/favicon.svg
@@ -1 +1,24 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 23d4cd8..ddb678e 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -14,6 +14,6 @@ import AppFooter from '@/components/AppFooter.vue'
diff --git a/frontend/src/__tests__/App.spec.ts b/frontend/src/__tests__/App.spec.ts
index eb8cc3f..be073f1 100644
--- a/frontend/src/__tests__/App.spec.ts
+++ b/frontend/src/__tests__/App.spec.ts
@@ -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')
})
})
diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts
index adfa44d..570e02a 100644
--- a/frontend/src/__tests__/AppHeader.spec.ts
+++ b/frontend/src/__tests__/AppHeader.spec.ts
@@ -25,6 +25,11 @@ function createTestRouter() {
name: 'orders',
component: { template: 'Orders
' },
},
+ {
+ path: '/admin',
+ name: 'admin',
+ component: { template: 'Admin
' },
+ },
],
})
}
@@ -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((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('/')
})
})
})
diff --git a/frontend/src/assets/styles/base.css b/frontend/src/assets/styles/base.css
new file mode 100644
index 0000000..05f5972
--- /dev/null
+++ b/frontend/src/assets/styles/base.css
@@ -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;
+}
diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue
index 3234322..0d11d4c 100644
--- a/frontend/src/components/AppFooter.vue
+++ b/frontend/src/components/AppFooter.vue
@@ -4,39 +4,67 @@ import { RouterLink } from 'vue-router'
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index 6262ef5..fc251b1 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -1,83 +1,170 @@
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 7e63a96..7e0569d 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -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)