From 5fa903d9afdd7de5a7f61fa32660f111f026ad7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Thu, 14 May 2026 16:02:14 +0200 Subject: [PATCH] feat: build out compose page with template selector, letter editor, and preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/e2e/compose.spec.ts | 117 +++++++++ frontend/src/__tests__/ComposePage.spec.ts | 213 ++++++++++++++-- frontend/src/api/orders.ts | 11 + frontend/src/data/templates.ts | 54 ++++ frontend/src/pages/ComposePage.vue | 279 ++++++++++++++++++++- 5 files changed, 645 insertions(+), 29 deletions(-) create mode 100644 frontend/e2e/compose.spec.ts create mode 100644 frontend/src/data/templates.ts diff --git a/frontend/e2e/compose.spec.ts b/frontend/e2e/compose.spec.ts new file mode 100644 index 0000000..3b4f958 --- /dev/null +++ b/frontend/e2e/compose.spec.ts @@ -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() + }) +}) diff --git a/frontend/src/__tests__/ComposePage.spec.ts b/frontend/src/__tests__/ComposePage.spec.ts index 6c00756..4c2492f 100644 --- a/frontend/src/__tests__/ComposePage.spec.ts +++ b/frontend/src/__tests__/ComposePage.spec.ts @@ -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: '
Home
' }, + }, + { + path: '/compose', + name: 'compose', + component: ComposePage, + }, + { + path: '/orders', + name: 'orders', + component: { template: '
Orders
' }, + }, + ], }) } +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) }) }) diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 9ae9290..586c110 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -12,3 +12,14 @@ export interface Order { export function fetchOrders(): Promise { return request('/orders') } + +export function createOrder( + plate: string, + template: string | null, + letterText: string, +): Promise { + return request('/orders', { + method: 'POST', + body: JSON.stringify({ plate, template, letterText }), + }) +} diff --git a/frontend/src/data/templates.ts b/frontend/src/data/templates.ts new file mode 100644 index 0000000..7c4771d --- /dev/null +++ b/frontend/src/data/templates.ts @@ -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 så 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 på att sälja den, så får du gärna höra av dig. + +Du kan nå mig på: [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 så snart som möjligt. + +Hoppas detta var till hjälp!`, + }, + { + name: 'Synpunkter på körbeteende', + 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. + +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 så ibland, men jag ville nå ut för att lösa det på ett trevligt sätt. + +Ha det bra!`, + }, + { + name: 'Fritt meddelande', + body: '', + }, +] diff --git a/frontend/src/pages/ComposePage.vue b/frontend/src/pages/ComposePage.vue index e8e0d65..6192f6f 100644 --- a/frontend/src/pages/ComposePage.vue +++ b/frontend/src/pages/ComposePage.vue @@ -1,14 +1,116 @@ @@ -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; +}