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() ).toBeVisible()
await expect(page.getByText('Transportstyrelsens fordonsregister')).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() const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Detta brev skickades via BilHej.se') 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 { export interface LetterTemplate {
name: string name: string
icon: string
body: string body: string
} }
export const templates: LetterTemplate[] = [ export const templates: LetterTemplate[] = [
{ {
name: 'Komplimang', name: 'Komplimang',
icon: '🌟',
body: `Hej! 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. 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!`, Ha en trevlig dag!`,
}, },
{ {
name: 'Jag vill köpa din bil', name: 'Köpförfrågan',
icon: '🚗',
body: `Hej! 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. 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', name: 'Tips / servicebehov',
icon: '🔧',
body: `Hej! 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. 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!`, Hoppas detta var till hjälp!`,
}, },
{ {
name: 'Synpunkter på körbeteende', name: 'Körbeteende',
icon: '🛣️',
body: `Hej! 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. 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', name: 'Tuta / frustration',
icon: '📢',
body: `Hej! 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. 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!`, 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', name: 'Fritt meddelande',
icon: '✏️',
body: '', body: '',
}, },
] ]

View file

@ -2,6 +2,8 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders' import { createOrder } from '@/api/orders'
import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -10,6 +12,7 @@ const plate = computed(() => (route.query.plate as string) || '')
const letterText = ref('') const letterText = ref('')
const submitting = ref(false) const submitting = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const showPicker = ref(false)
const charCount = computed(() => letterText.value.length) const charCount = computed(() => letterText.value.length)
const maxChars = 1000 const maxChars = 1000
@ -20,6 +23,10 @@ const canSubmit = computed(
const GDPR_FOOTER = 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' '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() { async function handleSubmit() {
if (!canSubmit.value) return if (!canSubmit.value) return
@ -50,7 +57,16 @@ async function handleSubmit() {
<form v-if="plate" class="compose__form" @submit.prevent="handleSubmit"> <form v-if="plate" class="compose__form" @submit.prevent="handleSubmit">
<div class="compose__field"> <div class="compose__field">
<div class="compose__label-row">
<label for="letter" class="compose__label">Ditt meddelande</label> <label for="letter" class="compose__label">Ditt meddelande</label>
<button
type="button"
class="compose__templates-btn"
@click="showPicker = true"
>
Visa mallar
</button>
</div>
<textarea <textarea
id="letter" id="letter"
v-model="letterText" v-model="letterText"
@ -85,6 +101,12 @@ async function handleSubmit() {
{{ submitting ? 'Skickar...' : 'Skicka brev (49 kr)' }} {{ submitting ? 'Skickar...' : 'Skicka brev (49 kr)' }}
</button> </button>
</form> </form>
<TemplatePicker
v-if="showPicker"
@select="handleTemplateSelect"
@close="showPicker = false"
/>
</div> </div>
</template> </template>
@ -144,6 +166,31 @@ async function handleSubmit() {
color: #4a5568; 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 { .compose__textarea {
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;