feat: build out compose page with template selector, letter editor, and preview

- Add createOrder(plate, template, letterText) to frontend api/orders.ts
- Create data/templates.ts with 6 Swedish letter templates (Komplimang,
  Jag vill köpa din bil, Tips / servicebehov, Synpunkter på körbeteende,
  Tuta / frustration, Fritt meddelande) with pre-filled body text
- Rewrite ComposePage.vue with full compose flow:
  - Template selector dropdown (Fritt meddelande selected by default)
  - Textarea with 1000-char limit and live character counter
  - Inline A4 letter preview with plate, body, and GDPR Art. 14 footer
  - 'Skicka brev (49 kr)' submit button, disabled when empty
  - On success: redirects to /orders; on error: shows error message
  - Shows error with back link if no plate in route query
- Add 12 Vitest tests for ComposePage (template fill, char counter, submit
  validation, createOrder call, navigation, null template for Fritt meddelande)
- Add 8 Playwright E2E tests (auth guard, no-plate error, template selection,
  textarea edit, submit button state, order creation, preview content)
This commit is contained in:
Joakim Mörling 2026-05-14 16:02:14 +02:00
parent 55f0fd8771
commit 5fa903d9af
5 changed files with 645 additions and 29 deletions

View file

@ -0,0 +1,117 @@
import { test, expect } from '@playwright/test'
test.describe('Compose flow', () => {
test('redirects unauthenticated user to login', async ({ page }) => {
await page.goto('/compose?plate=ABC123')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/compose/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
})
test('shows error when no plate is provided', 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')
await expect(page.getByText('Inget registreringsnummer valt')).toBeVisible()
})
test('displays plate and template selector', 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 expect(
page.getByRole('heading', { name: 'Skriv ditt brev' }),
).toBeVisible()
await expect(page.getByText('ABC123')).toBeVisible()
await expect(page.getByLabel('Välj mall')).toBeVisible()
})
test('selecting template fills textarea', 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.getByLabel('Välj mall').selectOption('Komplimang')
const textarea = page.getByLabel('Ditt meddelande')
await expect(textarea).toHaveValue(/jättefin/)
})
test('can edit textarea after selecting template', 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.getByLabel('Välj mall').selectOption('Komplimang')
const textarea = page.getByLabel('Ditt meddelande')
await textarea.fill('Custom text')
await expect(textarea).toHaveValue('Custom text')
})
test('submit button disabled when textarea is empty', 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')
const button = page.getByRole('button', { name: 'Skicka brev (49 kr)' })
await expect(button).toBeDisabled()
})
test('can create order and navigate to orders page', 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.getByLabel('Välj mall').selectOption('Komplimang')
const button = page.getByRole('button', { name: 'Skicka brev (49 kr)' })
await expect(button).toBeEnabled()
await button.click()
await expect(page).toHaveURL('/orders')
await expect(
page.getByRole('heading', { name: 'Mina beställningar' }),
).toBeVisible()
})
test('preview shows letter content and GDPR footer', 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.getByLabel('Välj mall').selectOption('Komplimang')
await expect(
page.getByText('Detta brev skickades via BilHej.se'),
).toBeVisible()
await expect(page.getByText('Transportstyrelsens fordonsregister')).toBeVisible()
})
})

View file

@ -1,43 +1,206 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import ComposePage from '@/pages/ComposePage.vue'
vi.mock('@/api/orders', () => ({
createOrder: vi.fn(),
}))
import { createOrder } from '@/api/orders'
const mockCreateOrder = vi.mocked(createOrder)
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [{ path: '/compose', name: 'compose', component: ComposePage }],
routes: [
{
path: '/',
name: 'home',
component: { template: '<div>Home</div>' },
},
{
path: '/compose',
name: 'compose',
component: ComposePage,
},
{
path: '/orders',
name: 'orders',
component: { template: '<div>Orders</div>' },
},
],
})
}
async function mountPage(plate = 'ABC123') {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'compose', query: { plate } })
await router.isReady()
const wrapper = mount(ComposePage, {
global: {
plugins: [router, pinia],
},
})
return { wrapper, router }
}
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')
beforeEach(() => {
vi.clearAllMocks()
})
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('shows plate from route query', async () => {
const { wrapper } = await mountPage('XYZ789')
expect(wrapper.text()).toContain('XYZ789')
})
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] },
it('shows error when no plate is provided', async () => {
const { wrapper } = await mountPage('')
expect(wrapper.text()).toContain('Inget registreringsnummer valt')
})
it('has all 6 template options', async () => {
const { wrapper } = await mountPage()
const select = wrapper.find('select')
const options = select.findAll('option')
expect(options).toHaveLength(6)
expect(options[0].text()).toBe('Komplimang')
expect(options[5].text()).toBe('Fritt meddelande')
})
it('selects Fritt meddelande by default', async () => {
const { wrapper } = await mountPage()
const select = wrapper.find('select')
expect((select.element as HTMLSelectElement).value).toBe('Fritt meddelande')
})
it('fills textarea when template is selected', async () => {
const { wrapper } = await mountPage()
const select = wrapper.find('select')
await select.setValue('Komplimang')
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toContain('jättefin')
})
it('updates character counter on input', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Hej!')
expect(wrapper.text()).toContain('4 / 1000 tecken')
})
it('shows warning when character count exceeds 900', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('a'.repeat(901))
const counter = wrapper.find('.compose__counter')
expect(counter.classes()).toContain('compose__counter--warn')
})
it('disables submit button when textarea is empty', async () => {
const { wrapper } = await mountPage()
const button = wrapper.find('button[type="submit"]')
expect(button.attributes('disabled')).toBeDefined()
})
it('enables submit button when textarea has text', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Hej!')
const button = wrapper.find('button[type="submit"]')
expect(button.attributes('disabled')).toBeUndefined()
})
it('calls createOrder on submit', async () => {
mockCreateOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
template: 'Komplimang',
status: 'pending_payment',
trackingId: null,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper } = await mountPage()
const select = wrapper.find('select')
await select.setValue('Komplimang')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(mockCreateOrder).toHaveBeenCalledWith(
'ABC123',
'Komplimang',
expect.stringContaining('jättefin'),
)
})
})
it('navigates to /orders on success', async () => {
mockCreateOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
template: null,
status: 'pending_payment',
trackingId: null,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper, router } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Test letter')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders')
})
})
it('shows error message on API failure', async () => {
mockCreateOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Test letter')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte skapa beställningen')
})
})
it('sends null template for Fritt meddelande', async () => {
mockCreateOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
template: null,
status: 'pending_payment',
trackingId: null,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Custom text')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(mockCreateOrder).toHaveBeenCalledWith(
'ABC123',
null,
'Custom text',
)
})
expect(wrapper.find('.compose__plate').exists()).toBe(false)
})
})

View file

@ -12,3 +12,14 @@ export interface Order {
export function fetchOrders(): Promise<Order[]> {
return request<Order[]>('/orders')
}
export function createOrder(
plate: string,
template: string | null,
letterText: string,
): Promise<Order> {
return request<Order>('/orders', {
method: 'POST',
body: JSON.stringify({ plate, template, letterText }),
})
}

View file

@ -0,0 +1,54 @@
export interface LetterTemplate {
name: string
body: string
}
export const templates: LetterTemplate[] = [
{
name: 'Komplimang',
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.
Ha en trevlig dag!`,
},
{
name: 'Jag vill köpa din bil',
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.
Du kan mig : [din e-postadress eller telefonnummer]
Vänliga hälsningar,
[Ditt namn]`,
},
{
name: 'Tips / servicebehov',
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.
Hoppas detta var till hjälp!`,
},
{
name: 'Synpunkter på körbeteende',
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.
Tack för att du lyssnar!`,
},
{
name: 'Tuta / frustration',
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: 'Fritt meddelande',
body: '',
},
]

View file

@ -1,14 +1,116 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { templates } from '@/data/templates'
import { createOrder } from '@/api/orders'
const router = useRouter()
const route = useRoute()
const plate = (route.query.plate as string) || ''
const plate = computed(() => (route.query.plate as string) || '')
const selectedTemplate = ref('Fritt meddelande')
const letterText = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const charCount = computed(() => letterText.value.length)
const maxChars = 1000
const canSubmit = computed(
() => letterText.value.trim().length > 0 && !submitting.value,
)
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'
watch(selectedTemplate, (name) => {
const template = templates.find((t) => t.name === name)
if (template) {
letterText.value = template.body
}
})
async function handleSubmit() {
if (!canSubmit.value) return
submitting.value = true
errorMessage.value = ''
try {
const templateName =
selectedTemplate.value === 'Fritt meddelande'
? null
: selectedTemplate.value
await createOrder(plate.value, templateName, letterText.value)
await router.push({ name: 'orders' })
} catch {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="compose">
<h1>Skriv ditt brev</h1>
<p v-if="plate" class="compose__plate">Registreringsnummer: {{ plate }}</p>
<h1 class="compose__title">Skriv ditt brev</h1>
<p v-if="plate" class="compose__plate">
Registreringsnummer: <strong>{{ plate }}</strong>
</p>
<p v-if="!plate" class="compose__error">
Inget registreringsnummer valt.
<RouterLink to="/"> tillbaka</RouterLink>
</p>
<form v-if="plate" class="compose__form" @submit.prevent="handleSubmit">
<div class="compose__field">
<label for="template" class="compose__label">Välj mall</label>
<select
id="template"
v-model="selectedTemplate"
class="compose__select"
>
<option v-for="t in templates" :key="t.name" :value="t.name">
{{ t.name }}
</option>
</select>
</div>
<div class="compose__field">
<label for="letter" class="compose__label">Ditt meddelande</label>
<textarea
id="letter"
v-model="letterText"
class="compose__textarea"
:maxlength="maxChars"
rows="10"
placeholder="Skriv ditt meddelande här..."
></textarea>
<p
class="compose__counter"
:class="{ 'compose__counter--warn': charCount > 900 }"
>
{{ charCount }} / {{ maxChars }} tecken
</p>
</div>
<div class="compose__preview">
<h2 class="compose__preview-title">Förhandsvisning</h2>
<div class="compose__preview-page">
<p class="compose__preview-plate">Registreringsnummer: {{ plate }}</p>
<p class="compose__preview-body" style="white-space: pre-wrap">
{{ letterText }}
</p>
<hr class="compose__preview-divider" />
<p class="compose__preview-footer">{{ GDPR_FOOTER }}</p>
</div>
</div>
<p v-if="errorMessage" class="compose__api-error">{{ errorMessage }}</p>
<button type="submit" class="compose__submit" :disabled="!canSubmit">
{{ submitting ? 'Skickar...' : 'Skicka brev (49 kr)' }}
</button>
</form>
</div>
</template>
@ -19,8 +121,177 @@ const plate = (route.query.plate as string) || ''
padding: 0 1rem;
}
.compose__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.compose__plate {
margin: 0 0 1.5rem 0;
color: #4a5568;
font-size: 0.875rem;
}
.compose__error {
margin: 2rem 0;
padding: 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
}
.compose__error a {
color: #4299e1;
text-decoration: none;
}
.compose__error a:hover {
text-decoration: underline;
}
.compose__form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.compose__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.compose__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.compose__select {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
background: #fff;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.compose__select:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.compose__textarea {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
font-family: inherit;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
resize: vertical;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.compose__textarea:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.compose__counter {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
text-align: right;
}
.compose__counter--warn {
color: #e53e3e;
}
.compose__preview {
margin-top: 0.5rem;
}
.compose__preview-title {
margin: 0 0 0.75rem 0;
font-size: 1rem;
color: #4a5568;
}
.compose__preview-page {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 2rem 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.9375rem;
line-height: 1.6;
color: #2d3748;
}
.compose__preview-plate {
margin: 0 0 1.5rem 0;
font-family: monospace;
font-size: 0.875rem;
color: #718096;
}
.compose__preview-body {
margin: 0 0 1.5rem 0;
min-height: 4rem;
}
.compose__preview-divider {
margin: 1.5rem 0;
border: none;
border-top: 1px solid #e2e8f0;
}
.compose__preview-footer {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
line-height: 1.5;
}
.compose__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
}
.compose__submit {
width: 100%;
padding: 0.875rem 1.5rem;
background: #38a169;
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.compose__submit:hover:not(:disabled) {
background: #2f855a;
}
.compose__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>