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 @@
-
Skriv ditt brev
-
Registreringsnummer: {{ plate }}
+
Skriv ditt brev
+
+ Registreringsnummer: {{ plate }}
+
+
+ Inget registreringsnummer valt.
+ Gå tillbaka
+
+
+
@@ -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;
+}