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:
parent
6ab5e2f707
commit
96508d63cd
6 changed files with 353 additions and 3 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
59
frontend/src/__tests__/TemplatePicker.spec.ts
Normal file
59
frontend/src/__tests__/TemplatePicker.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
151
frontend/src/components/TemplatePicker.vue
Normal file
151
frontend/src/components/TemplatePicker.vue
Normal 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')">×</button>
|
||||
</div>
|
||||
<p class="modal__subtitle">
|
||||
Klicka på 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>
|
||||
|
|
@ -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 så 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 på att sälja den, så 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 så 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 på en situation i trafiken där jag reagerade på 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 så ibland, men jag ville nå ut för att lösa det på 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 nå mig på: [din e-postadress eller telefonnummer]
|
||||
|
||||
Vänliga hälsningar,
|
||||
[Ditt namn]`,
|
||||
},
|
||||
{
|
||||
name: 'Fritt meddelande',
|
||||
icon: '✏️',
|
||||
body: '',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue