feat: add template picker modal to compose page

- Add LetterTemplate.icon field and 7th template 'Mindre parkeringsskada' (🅿️)
- Create TemplatePicker.vue component: modal overlay with 2-column card grid,
  emits 'select' and 'close' events, closes on overlay click
- Add ' Visa mallar' pill button above textarea in ComposePage
- Clicking button opens TemplatePicker modal, selecting a template fills
  textarea and closes modal
- Style button as pill/badge with light blue background and icon
- Add 7 Vitest tests for TemplatePicker (renders cards, emits events, close
  behavior, parking damage template)
- Add 4 Vitest tests for ComposePage template picker integration
- Add 2 Playwright E2E tests (opens picker, fills textarea and closes)
This commit is contained in:
Joakim Mörling 2026-05-14 17:39:21 +02:00
parent 6ab5e2f707
commit 96508d63cd
6 changed files with 353 additions and 3 deletions

View file

@ -84,4 +84,39 @@ test.describe('Compose flow', () => {
).toBeVisible()
await expect(page.getByText('Transportstyrelsens fordonsregister')).toBeVisible()
})
test('Visa mallar button opens template picker', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/compose?plate=ABC123')
await page.getByRole('button', { name: 'Visa mallar' }).click()
await expect(page.getByRole('heading', { name: 'Välj en mall' })).toBeVisible()
await expect(page.getByText('Komplimang')).toBeVisible()
await expect(page.getByText('Köpförfrågan')).toBeVisible()
})
test('selecting template fills textarea and closes picker', async ({
page,
}) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/compose?plate=ABC123')
await page.getByRole('button', { name: 'Visa mallar' }).click()
await page.getByText('Komplimang').click()
const textarea = page.getByLabel('Ditt meddelande')
await expect(textarea).toHaveValue(/jättefin/)
await expect(page.getByRole('heading', { name: 'Välj en mall' })).not.toBeVisible()
})
})

View file

@ -168,4 +168,43 @@ describe('ComposePage', () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Detta brev skickades via BilHej.se')
})
it('shows Visa mallar button', async () => {
const { wrapper } = await mountPage()
const btn = wrapper.find('.compose__templates-btn')
expect(btn.exists()).toBe(true)
expect(btn.text()).toContain('Visa mallar')
})
it('opens template picker when Visa mallar is clicked', async () => {
const { wrapper } = await mountPage()
const btn = wrapper.find('.compose__templates-btn')
await btn.trigger('click')
expect(wrapper.text()).toContain('Välj en mall')
expect(wrapper.text()).toContain('Komplimang')
})
it('fills textarea when template is selected', async () => {
const { wrapper } = await mountPage()
const btn = wrapper.find('.compose__templates-btn')
await btn.trigger('click')
const cards = wrapper.findAll('.modal__card')
await cards[0].trigger('click')
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toContain('jättefin')
})
it('closes picker after template is selected', async () => {
const { wrapper } = await mountPage()
const btn = wrapper.find('.compose__templates-btn')
await btn.trigger('click')
const cards = wrapper.findAll('.modal__card')
await cards[0].trigger('click')
expect(wrapper.find('.modal-overlay').exists()).toBe(false)
})
})

View file

@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TemplatePicker from '@/components/TemplatePicker.vue'
describe('TemplatePicker', () => {
it('renders all template cards', () => {
const wrapper = mount(TemplatePicker)
const cards = wrapper.findAll('.modal__card')
expect(cards).toHaveLength(7)
})
it('shows template names', () => {
const wrapper = mount(TemplatePicker)
expect(wrapper.text()).toContain('Komplimang')
expect(wrapper.text()).toContain('Köpförfrågan')
expect(wrapper.text()).toContain('Fritt meddelande')
})
it('emits select event with template data when card is clicked', async () => {
const wrapper = mount(TemplatePicker)
const cards = wrapper.findAll('.modal__card')
await cards[0].trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
expect(wrapper.emitted('select')![0][0]).toMatchObject({
name: 'Komplimang',
icon: '🌟',
})
})
it('emits close event when card is clicked', async () => {
const wrapper = mount(TemplatePicker)
const cards = wrapper.findAll('.modal__card')
await cards[0].trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('emits close event when close button is clicked', async () => {
const wrapper = mount(TemplatePicker)
const closeBtn = wrapper.find('.modal__close')
await closeBtn.trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('emits close event when overlay is clicked', async () => {
const wrapper = mount(TemplatePicker)
const overlay = wrapper.find('.modal-overlay')
await overlay.trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('includes new parking damage template', () => {
const wrapper = mount(TemplatePicker)
expect(wrapper.text()).toContain('Mindre parkeringsskada')
})
})

View file

@ -0,0 +1,151 @@
<script setup lang="ts">
import { templates, type LetterTemplate } from '@/data/templates'
const emit = defineEmits<{
(e: 'select', template: LetterTemplate): void
(e: 'close'): void
}>()
function handleSelect(template: LetterTemplate) {
emit('select', template)
emit('close')
}
</script>
<template>
<div class="modal-overlay" @click.self="emit('close')">
<div class="modal">
<div class="modal__header">
<h2 class="modal__title">Välj en mall</h2>
<button class="modal__close" @click="emit('close')">&times;</button>
</div>
<p class="modal__subtitle">
Klicka en mall för att fylla i meddelandetexten.
</p>
<div class="modal__grid">
<button
v-for="t in templates"
:key="t.name"
class="modal__card"
@click="handleSelect(t)"
>
<span class="modal__card-icon">{{ t.icon }}</span>
<span class="modal__card-name">{{ t.name }}</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: 1rem;
width: 100%;
max-width: 28rem;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.modal__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1.5rem 0;
}
.modal__title {
margin: 0;
font-size: 1.25rem;
color: #1a202c;
}
.modal__close {
background: none;
border: none;
font-size: 1.5rem;
color: #a0aec0;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition:
color 0.15s,
background 0.15s;
}
.modal__close:hover {
color: #4a5568;
background: #f7fafc;
}
.modal__subtitle {
margin: 0.5rem 0 0;
padding: 0 1.5rem;
font-size: 0.875rem;
color: #718096;
}
.modal__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding: 1.25rem 1.5rem 1.5rem;
}
.modal__card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem 0.75rem;
background: #f7fafc;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
transform 0.1s;
font-family: inherit;
}
.modal__card:hover {
border-color: #4299e1;
background: #ebf8ff;
transform: translateY(-1px);
}
.modal__card:active {
transform: translateY(0);
}
.modal__card-icon {
font-size: 2rem;
}
.modal__card-name {
font-size: 0.8125rem;
font-weight: 600;
color: #4a5568;
text-align: center;
line-height: 1.3;
}
@media (max-width: 400px) {
.modal__grid {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -1,11 +1,13 @@
export interface LetterTemplate {
name: string
icon: string
body: string
}
export const templates: LetterTemplate[] = [
{
name: 'Komplimang',
icon: '🌟',
body: `Hej!
Jag ville bara säga att din bil är jättefin! Det syns att den är väl omhändertagen och jag uppskattar verkligen att du tar hand om den bra.
@ -13,7 +15,8 @@ Jag ville bara säga att din bil är jättefin! Det syns att den är väl omhän
Ha en trevlig dag!`,
},
{
name: 'Jag vill köpa din bil',
name: 'Köpförfrågan',
icon: '🚗',
body: `Hej!
Jag är intresserad av att köpa din bil. Om du någon gång funderar att sälja den, får du gärna höra av dig.
@ -25,6 +28,7 @@ Vänliga hälsningar,
},
{
name: 'Tips / servicebehov',
icon: '🔧',
body: `Hej!
Jag ville tipsa dig om att jag märkte att din bil behöver lite uppmärksamhet. Det kan vara bra att kolla upp det snart som möjligt.
@ -32,7 +36,8 @@ Jag ville tipsa dig om att jag märkte att din bil behöver lite uppmärksamhet.
Hoppas detta var till hjälp!`,
},
{
name: 'Synpunkter på körbeteende',
name: 'Körbeteende',
icon: '🛣️',
body: `Hej!
Jag ville uppmärksamma dig en situation i trafiken där jag reagerade ditt körbeteende. Jag menar inget illa utan vill bara ge en vänlig påminnelse om att vara extra uppmärksam.
@ -41,14 +46,28 @@ Tack för att du lyssnar!`,
},
{
name: 'Tuta / frustration',
icon: '📢',
body: `Hej!
Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir ibland, men jag ville ut för att lösa det ett trevligt sätt.
Ha det bra!`,
},
{
name: 'Mindre parkeringsskada',
icon: '🅿️',
body: `Hej!
Jag råkade skada din bil lite när jag parkerade. Det var inte meningen och jag ber om ursäkt. Jag vill gärna att vi löser det här tillsammans.
Du kan mig : [din e-postadress eller telefonnummer]
Vänliga hälsningar,
[Ditt namn]`,
},
{
name: 'Fritt meddelande',
icon: '✏️',
body: '',
},
]

View file

@ -2,6 +2,8 @@
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders'
import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue'
const router = useRouter()
const route = useRoute()
@ -10,6 +12,7 @@ const plate = computed(() => (route.query.plate as string) || '')
const letterText = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const showPicker = ref(false)
const charCount = computed(() => letterText.value.length)
const maxChars = 1000
@ -20,6 +23,10 @@ const canSubmit = computed(
const GDPR_FOOTER =
'Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se'
function handleTemplateSelect(template: LetterTemplate) {
letterText.value = template.body
}
async function handleSubmit() {
if (!canSubmit.value) return
@ -50,7 +57,16 @@ async function handleSubmit() {
<form v-if="plate" class="compose__form" @submit.prevent="handleSubmit">
<div class="compose__field">
<label for="letter" class="compose__label">Ditt meddelande</label>
<div class="compose__label-row">
<label for="letter" class="compose__label">Ditt meddelande</label>
<button
type="button"
class="compose__templates-btn"
@click="showPicker = true"
>
Visa mallar
</button>
</div>
<textarea
id="letter"
v-model="letterText"
@ -85,6 +101,12 @@ async function handleSubmit() {
{{ submitting ? 'Skickar...' : 'Skicka brev (49 kr)' }}
</button>
</form>
<TemplatePicker
v-if="showPicker"
@select="handleTemplateSelect"
@close="showPicker = false"
/>
</div>
</template>
@ -144,6 +166,31 @@ async function handleSubmit() {
color: #4a5568;
}
.compose__label-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.compose__templates-btn {
background: #ebf8ff;
border: 1px solid #bee3f8;
color: #2b6cb0;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
transition:
background 0.15s,
border-color 0.15s;
}
.compose__templates-btn:hover {
background: #bee3f8;
border-color: #90cdf4;
}
.compose__textarea {
width: 100%;
padding: 0.75rem 1rem;