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:
parent
210ac87ede
commit
4c6094446b
15 changed files with 451 additions and 6 deletions
|
|
@ -1,7 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<AppHeader />
|
||||
<main class="app__main">
|
||||
<RouterView />
|
||||
</main>
|
||||
<AppFooter />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.app__main {
|
||||
min-height: calc(100vh - 12rem);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
10
frontend/src/__tests__/AboutPage.spec.ts
Normal file
10
frontend/src/__tests__/AboutPage.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
54
frontend/src/__tests__/AppFooter.spec.ts
Normal file
54
frontend/src/__tests__/AppFooter.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
33
frontend/src/__tests__/AppHeader.spec.ts
Normal file
33
frontend/src/__tests__/AppHeader.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
43
frontend/src/__tests__/ComposePage.spec.ts
Normal file
43
frontend/src/__tests__/ComposePage.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
10
frontend/src/__tests__/ContactPage.spec.ts
Normal file
10
frontend/src/__tests__/ContactPage.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof createTestRouter>) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
42
frontend/src/components/AppFooter.vue
Normal file
42
frontend/src/components/AppFooter.vue
Normal 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>
|
||||
45
frontend/src/components/AppHeader.vue
Normal file
45
frontend/src/components/AppHeader.vue
Normal 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>
|
||||
19
frontend/src/pages/AboutPage.vue
Normal file
19
frontend/src/pages/AboutPage.vue
Normal 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>
|
||||
26
frontend/src/pages/ComposePage.vue
Normal file
26
frontend/src/pages/ComposePage.vue
Normal 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>
|
||||
19
frontend/src/pages/ContactPage.vue
Normal file
19
frontend/src/pages/ContactPage.vue
Normal 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 på
|
||||
<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>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import PlateInput from '@/components/PlateInput.vue'
|
||||
import VehicleInfo from '@/components/VehicleInfo.vue'
|
||||
import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue'
|
||||
|
|
@ -36,7 +37,6 @@ function handleLookup(lookedUpPlate: string) {
|
|||
|
||||
<template>
|
||||
<div class="home">
|
||||
<h1>BilHälsning</h1>
|
||||
<p class="home__subtitle">Skicka ett brev till en fordonsägare</p>
|
||||
|
||||
<PlateInput v-model="plate" @lookup="handleLookup" />
|
||||
|
|
@ -47,6 +47,14 @@ function handleLookup(lookedUpPlate: string) {
|
|||
:not-found="notFound"
|
||||
:plate="plate"
|
||||
/>
|
||||
|
||||
<RouterLink
|
||||
v-if="vehicle"
|
||||
:to="{ name: 'compose', query: { plate } }"
|
||||
class="home__cta"
|
||||
>
|
||||
Skicka ett brev till ägaren
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -61,4 +69,21 @@ function handleLookup(lookedUpPlate: string) {
|
|||
color: #718096;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
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({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -9,6 +12,21 @@ const router = createRouter({
|
|||
name: 'home',
|
||||
component: HomePage,
|
||||
},
|
||||
{
|
||||
path: '/compose',
|
||||
name: 'compose',
|
||||
component: ComposePage,
|
||||
},
|
||||
{
|
||||
path: '/om',
|
||||
name: 'about',
|
||||
component: AboutPage,
|
||||
},
|
||||
{
|
||||
path: '/kontakt',
|
||||
name: 'contact',
|
||||
component: ContactPage,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue