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 + + + + + + + + + + + + + + + + + + + + + + + B + \ 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)