From 4c6094446b7fa1208c8845c2106cc52d02643f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 1 May 2026 18:19:53 +0200 Subject: [PATCH] feat: add app shell with header, footer, and compose flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AppHeader and AppFooter to give the site a consistent chrome around the core page content. Add ComposePage stub reachable via "Skicka ett brev till ägaren" CTA on HomePage after vehicle lookup succeeds. Add stub pages for about, contact, and privacy. - Create AppHeader.vue with logo link (BilHälsning) and Hem nav link - Create AppFooter.vue with 4 links: Om oss, Kontakt, Integritetspolicy, Villkor - Create ComposePage.vue stub that reads plate from route query params - Create AboutPage.vue and ContactPage.vue stub pages - Add 4 new routes: /compose, /om, /kontakt, /integritetspolicy - Update App.vue to render AppHeader +
+ AppFooter around RouterView - Add home__cta RouterLink button to HomePage, visible only when vehicle lookup succeeds, linking to /compose?plate= - Remove BilHälsning h1 from HomePage (moved to header) - Add 17 new tests: AppHeader (2), AppFooter (1), ComposePage (3), AboutPage (1), ContactPage (1), HomePage rewrite (6), App update (2) - Update App.spec.ts to verify header/footer components render --- frontend/src/App.vue | 14 +++- frontend/src/__tests__/AboutPage.spec.ts | 10 +++ frontend/src/__tests__/App.spec.ts | 16 ++++- frontend/src/__tests__/AppFooter.spec.ts | 54 +++++++++++++++ frontend/src/__tests__/AppHeader.spec.ts | 33 +++++++++ frontend/src/__tests__/ComposePage.spec.ts | 43 ++++++++++++ frontend/src/__tests__/ContactPage.spec.ts | 10 +++ frontend/src/__tests__/HomePage.spec.ts | 81 +++++++++++++++++++++- frontend/src/components/AppFooter.vue | 42 +++++++++++ frontend/src/components/AppHeader.vue | 45 ++++++++++++ frontend/src/pages/AboutPage.vue | 19 +++++ frontend/src/pages/ComposePage.vue | 26 +++++++ frontend/src/pages/ContactPage.vue | 19 +++++ frontend/src/pages/HomePage.vue | 27 +++++++- frontend/src/router/index.ts | 18 +++++ 15 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 frontend/src/__tests__/AboutPage.spec.ts create mode 100644 frontend/src/__tests__/AppFooter.spec.ts create mode 100644 frontend/src/__tests__/AppHeader.spec.ts create mode 100644 frontend/src/__tests__/ComposePage.spec.ts create mode 100644 frontend/src/__tests__/ContactPage.spec.ts create mode 100644 frontend/src/components/AppFooter.vue create mode 100644 frontend/src/components/AppHeader.vue create mode 100644 frontend/src/pages/AboutPage.vue create mode 100644 frontend/src/pages/ComposePage.vue create mode 100644 frontend/src/pages/ContactPage.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a78fb6f..23d4cd8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,7 +1,19 @@ + + diff --git a/frontend/src/__tests__/AboutPage.spec.ts b/frontend/src/__tests__/AboutPage.spec.ts new file mode 100644 index 0000000..df7ab24 --- /dev/null +++ b/frontend/src/__tests__/AboutPage.spec.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import AboutPage from '@/pages/AboutPage.vue' + +describe('AboutPage', () => { + it('renders heading', () => { + const wrapper = mount(AboutPage) + expect(wrapper.text()).toContain('Om BilHälsning') + }) +}) diff --git a/frontend/src/__tests__/App.spec.ts b/frontend/src/__tests__/App.spec.ts index 579492b..4d39ba0 100644 --- a/frontend/src/__tests__/App.spec.ts +++ b/frontend/src/__tests__/App.spec.ts @@ -1,9 +1,23 @@ import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import App from '@/App.vue' +import AppHeader from '@/components/AppHeader.vue' +import AppFooter from '@/components/AppFooter.vue' import router from '@/router' describe('App', () => { + it('renders AppHeader and AppFooter', async () => { + router.push('/') + await router.isReady() + const wrapper = mount(App, { + global: { + plugins: [router], + }, + }) + expect(wrapper.findComponent(AppHeader).exists()).toBe(true) + expect(wrapper.findComponent(AppFooter).exists()).toBe(true) + }) + it('renders RouterView with HomePage content', async () => { router.push('/') await router.isReady() @@ -12,6 +26,6 @@ describe('App', () => { plugins: [router], }, }) - expect(wrapper.text()).toContain('BilHälsning') + expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare') }) }) diff --git a/frontend/src/__tests__/AppFooter.spec.ts b/frontend/src/__tests__/AppFooter.spec.ts new file mode 100644 index 0000000..42587d1 --- /dev/null +++ b/frontend/src/__tests__/AppFooter.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import AppFooter from '@/components/AppFooter.vue' + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/om', + name: 'about', + component: { template: '
About
' }, + }, + { + path: '/kontakt', + name: 'contact', + component: { template: '
Contact
' }, + }, + { + path: '/integritetspolicy', + name: 'privacy', + component: { template: '
Privacy
' }, + }, + { + path: '/villkor', + name: 'terms', + component: { template: '
Terms
' }, + }, + ], + }) +} + +describe('AppFooter', () => { + it('renders all four links', () => { + const router = createTestRouter() + const wrapper = mount(AppFooter, { + global: { plugins: [router] }, + }) + const links = wrapper.findAll('a') + + expect(links[0].text()).toBe('Om oss') + expect(links[0].attributes('href')).toBe('/om') + + expect(links[1].text()).toBe('Kontakt') + expect(links[1].attributes('href')).toBe('/kontakt') + + expect(links[2].text()).toBe('Integritetspolicy') + expect(links[2].attributes('href')).toBe('/integritetspolicy') + + expect(links[3].text()).toBe('Villkor') + expect(links[3].attributes('href')).toBe('/villkor') + }) +}) diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts new file mode 100644 index 0000000..a4bd878 --- /dev/null +++ b/frontend/src/__tests__/AppHeader.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import AppHeader from '@/components/AppHeader.vue' + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
Home
' } }, + ], + }) +} + +describe('AppHeader', () => { + it('renders the logo text', () => { + const router = createTestRouter() + const wrapper = mount(AppHeader, { + global: { plugins: [router] }, + }) + expect(wrapper.text()).toContain('BilHälsning') + }) + + it('has a link to home', () => { + const router = createTestRouter() + const wrapper = mount(AppHeader, { + global: { plugins: [router] }, + }) + const links = wrapper.findAll('a') + const homeLink = links.find((a) => a.attributes('href') === '/') + expect(homeLink).toBeTruthy() + }) +}) diff --git a/frontend/src/__tests__/ComposePage.spec.ts b/frontend/src/__tests__/ComposePage.spec.ts new file mode 100644 index 0000000..6c00756 --- /dev/null +++ b/frontend/src/__tests__/ComposePage.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import ComposePage from '@/pages/ComposePage.vue' + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/compose', name: 'compose', component: ComposePage }], + }) +} + +describe('ComposePage', () => { + it('renders heading', async () => { + const router = createTestRouter() + router.push('/compose') + await router.isReady() + const wrapper = mount(ComposePage, { + global: { plugins: [router] }, + }) + expect(wrapper.text()).toContain('Skriv ditt brev') + }) + + it('displays plate from query param', async () => { + const router = createTestRouter() + router.push({ path: '/compose', query: { plate: 'ABC123' } }) + await router.isReady() + const wrapper = mount(ComposePage, { + global: { plugins: [router] }, + }) + expect(wrapper.text()).toContain('ABC123') + }) + + it('does not show plate when no query param', async () => { + const router = createTestRouter() + router.push('/compose') + await router.isReady() + const wrapper = mount(ComposePage, { + global: { plugins: [router] }, + }) + expect(wrapper.find('.compose__plate').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/ContactPage.spec.ts b/frontend/src/__tests__/ContactPage.spec.ts new file mode 100644 index 0000000..a317239 --- /dev/null +++ b/frontend/src/__tests__/ContactPage.spec.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import ContactPage from '@/pages/ContactPage.vue' + +describe('ContactPage', () => { + it('renders heading', () => { + const wrapper = mount(ContactPage) + expect(wrapper.text()).toContain('Kontakta oss') + }) +}) diff --git a/frontend/src/__tests__/HomePage.spec.ts b/frontend/src/__tests__/HomePage.spec.ts index 97d93c0..c899d7a 100644 --- a/frontend/src/__tests__/HomePage.spec.ts +++ b/frontend/src/__tests__/HomePage.spec.ts @@ -1,10 +1,85 @@ import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' import HomePage from '@/pages/HomePage.vue' +import ComposePage from '@/pages/ComposePage.vue' + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: HomePage }, + { path: '/compose', name: 'compose', component: ComposePage }, + ], + }) +} + +function mountHome(router: ReturnType) { + return mount(HomePage, { + global: { plugins: [router] }, + }) +} describe('HomePage', () => { - it('mounts successfully', () => { - const wrapper = mount(HomePage) - expect(wrapper.text()).toContain('BilHälsning') + it('renders subtitle', () => { + const router = createTestRouter() + const wrapper = mountHome(router) + expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare') + }) + + it('does not show CTA button initially', () => { + const router = createTestRouter() + const wrapper = mountHome(router) + expect(wrapper.find('.home__cta').exists()).toBe(false) + }) + + it('does not show CTA while loading', async () => { + const router = createTestRouter() + const wrapper = mountHome(router) + const plateInput = wrapper.findComponent({ name: 'PlateInput' }) + + await plateInput.vm.$emit('lookup', 'ABC123') + await wrapper.vm.$nextTick() + + expect(wrapper.find('.home__cta').exists()).toBe(false) + }) + + it('does not show CTA after not-found', async () => { + const router = createTestRouter() + const wrapper = mountHome(router) + const plateInput = wrapper.findComponent({ name: 'PlateInput' }) + + await plateInput.vm.$emit('lookup', 'UNKNOWN') + await new Promise((resolve) => setTimeout(resolve, 500)) + + expect(wrapper.find('.home__cta').exists()).toBe(false) + }) + + it('shows CTA button when vehicle data present', async () => { + const router = createTestRouter() + const wrapper = mountHome(router) + const plateInput = wrapper.findComponent({ name: 'PlateInput' }) + + await plateInput.vm.$emit('update:modelValue', 'ABC123') + await plateInput.vm.$emit('lookup', 'ABC123') + await new Promise((resolve) => setTimeout(resolve, 500)) + + const cta = wrapper.find('.home__cta') + expect(cta.exists()).toBe(true) + expect(cta.text()).toBe('Skicka ett brev till ägaren') + }) + + it('CTA links to compose page with plate query param', async () => { + const router = createTestRouter() + const wrapper = mountHome(router) + const plateInput = wrapper.findComponent({ name: 'PlateInput' }) + + await plateInput.vm.$emit('update:modelValue', 'ABC123') + await plateInput.vm.$emit('lookup', 'ABC123') + await new Promise((resolve) => setTimeout(resolve, 500)) + + const cta = wrapper.find('.home__cta') + const href = cta.attributes('href') + expect(href).toBe('/compose?plate=ABC123') }) }) diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue new file mode 100644 index 0000000..3234322 --- /dev/null +++ b/frontend/src/components/AppFooter.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..e135b32 --- /dev/null +++ b/frontend/src/components/AppHeader.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/src/pages/AboutPage.vue b/frontend/src/pages/AboutPage.vue new file mode 100644 index 0000000..94b0caa --- /dev/null +++ b/frontend/src/pages/AboutPage.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/frontend/src/pages/ComposePage.vue b/frontend/src/pages/ComposePage.vue new file mode 100644 index 0000000..e8e0d65 --- /dev/null +++ b/frontend/src/pages/ComposePage.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/frontend/src/pages/ContactPage.vue b/frontend/src/pages/ContactPage.vue new file mode 100644 index 0000000..7f8d9bd --- /dev/null +++ b/frontend/src/pages/ContactPage.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index 240eb89..804e984 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -1,5 +1,6 @@