feat: add app shell with header, footer, and compose flow

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 + <main> + AppFooter around RouterView
- Add home__cta RouterLink button to HomePage, visible only when vehicle
  lookup succeeds, linking to /compose?plate=<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
This commit is contained in:
Joakim Mörling 2026-05-01 18:19:53 +02:00
parent 210ac87ede
commit 4c6094446b
15 changed files with 451 additions and 6 deletions

View file

@ -1,7 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
</script> </script>
<template> <template>
<RouterView /> <AppHeader />
<main class="app__main">
<RouterView />
</main>
<AppFooter />
</template> </template>
<style>
.app__main {
min-height: calc(100vh - 12rem);
}
</style>

View file

@ -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')
})
})

View file

@ -1,9 +1,23 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import App from '@/App.vue' import App from '@/App.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
import router from '@/router' import router from '@/router'
describe('App', () => { 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 () => { it('renders RouterView with HomePage content', async () => {
router.push('/') router.push('/')
await router.isReady() await router.isReady()
@ -12,6 +26,6 @@ describe('App', () => {
plugins: [router], plugins: [router],
}, },
}) })
expect(wrapper.text()).toContain('BilHälsning') expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare')
}) })
}) })

View file

@ -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: '<div>About</div>' },
},
{
path: '/kontakt',
name: 'contact',
component: { template: '<div>Contact</div>' },
},
{
path: '/integritetspolicy',
name: 'privacy',
component: { template: '<div>Privacy</div>' },
},
{
path: '/villkor',
name: 'terms',
component: { template: '<div>Terms</div>' },
},
],
})
}
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')
})
})

View file

@ -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: '<div>Home</div>' } },
],
})
}
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()
})
})

View file

@ -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)
})
})

View file

@ -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')
})
})

View file

@ -1,10 +1,85 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import HomePage from '@/pages/HomePage.vue' 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<typeof createTestRouter>) {
return mount(HomePage, {
global: { plugins: [router] },
})
}
describe('HomePage', () => { describe('HomePage', () => {
it('mounts successfully', () => { it('renders subtitle', () => {
const wrapper = mount(HomePage) const router = createTestRouter()
expect(wrapper.text()).toContain('BilHälsning') 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')
}) })
}) })

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<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>
</footer>
</template>
<style scoped>
.app-footer {
background: #f7fafc;
border-top: 1px solid #e2e8f0;
padding: 1.5rem;
text-align: center;
}
.app-footer__links {
display: flex;
justify-content: center;
gap: 2rem;
flex-wrap: wrap;
}
.app-footer__link {
color: #718096;
text-decoration: none;
font-size: 0.8125rem;
}
.app-footer__link:hover {
color: #1a202c;
}
</style>

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</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>
</nav>
</header>
</template>
<style scoped>
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
background: #fff;
}
.app-header__logo {
font-size: 1.25rem;
font-weight: 700;
color: #1a202c;
text-decoration: none;
}
.app-header__nav {
display: flex;
gap: 1rem;
}
.app-header__link {
color: #4a5568;
text-decoration: none;
font-size: 0.875rem;
}
.app-header__link:hover {
color: #1a202c;
}
</style>

View file

@ -0,0 +1,19 @@
<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>
</template>
<style scoped>
.about {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
</style>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const plate = (route.query.plate as string) || ''
</script>
<template>
<div class="compose">
<h1>Skriv ditt brev</h1>
<p v-if="plate" class="compose__plate">Registreringsnummer: {{ plate }}</p>
</div>
</template>
<style scoped>
.compose {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.compose__plate {
color: #4a5568;
font-size: 0.875rem;
}
</style>

View file

@ -0,0 +1,19 @@
<script setup lang="ts"></script>
<template>
<div class="contact">
<h1>Kontakta oss</h1>
<p>
Har du frågor eller feedback? Hör av dig till oss
<a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>.
</p>
</div>
</template>
<style scoped>
.contact {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
</style>

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import PlateInput from '@/components/PlateInput.vue' import PlateInput from '@/components/PlateInput.vue'
import VehicleInfo from '@/components/VehicleInfo.vue' import VehicleInfo from '@/components/VehicleInfo.vue'
import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue' import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue'
@ -36,7 +37,6 @@ function handleLookup(lookedUpPlate: string) {
<template> <template>
<div class="home"> <div class="home">
<h1>BilHälsning</h1>
<p class="home__subtitle">Skicka ett brev till en fordonsägare</p> <p class="home__subtitle">Skicka ett brev till en fordonsägare</p>
<PlateInput v-model="plate" @lookup="handleLookup" /> <PlateInput v-model="plate" @lookup="handleLookup" />
@ -47,6 +47,14 @@ function handleLookup(lookedUpPlate: string) {
:not-found="notFound" :not-found="notFound"
:plate="plate" :plate="plate"
/> />
<RouterLink
v-if="vehicle"
:to="{ name: 'compose', query: { plate } }"
class="home__cta"
>
Skicka ett brev till ägaren
</RouterLink>
</div> </div>
</template> </template>
@ -61,4 +69,21 @@ function handleLookup(lookedUpPlate: string) {
color: #718096; color: #718096;
margin: 0 0 1.5rem 0; margin: 0 0 1.5rem 0;
} }
.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;
}
.home__cta:hover {
background: #2f855a;
}
</style> </style>

View file

@ -1,5 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import HomePage from '@/pages/HomePage.vue' import HomePage from '@/pages/HomePage.vue'
import ComposePage from '@/pages/ComposePage.vue'
import AboutPage from '@/pages/AboutPage.vue'
import ContactPage from '@/pages/ContactPage.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -9,6 +12,21 @@ const router = createRouter({
name: 'home', name: 'home',
component: HomePage, component: HomePage,
}, },
{
path: '/compose',
name: 'compose',
component: ComposePage,
},
{
path: '/om',
name: 'about',
component: AboutPage,
},
{
path: '/kontakt',
name: 'contact',
component: ContactPage,
},
], ],
}) })