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:
parent
55f0fd8771
commit
5fa903d9af
5 changed files with 645 additions and 29 deletions
117
frontend/e2e/compose.spec.ts
Normal file
117
frontend/e2e/compose.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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 { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
import ComposePage from '@/pages/ComposePage.vue'
|
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() {
|
function createTestRouter() {
|
||||||
return createRouter({
|
return createRouter({
|
||||||
history: createMemoryHistory(),
|
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', () => {
|
describe('ComposePage', () => {
|
||||||
it('renders heading', async () => {
|
beforeEach(() => {
|
||||||
const router = createTestRouter()
|
vi.clearAllMocks()
|
||||||
router.push('/compose')
|
|
||||||
await router.isReady()
|
|
||||||
const wrapper = mount(ComposePage, {
|
|
||||||
global: { plugins: [router] },
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('Skriv ditt brev')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('displays plate from query param', async () => {
|
it('shows plate from route query', async () => {
|
||||||
const router = createTestRouter()
|
const { wrapper } = await mountPage('XYZ789')
|
||||||
router.push({ path: '/compose', query: { plate: 'ABC123' } })
|
expect(wrapper.text()).toContain('XYZ789')
|
||||||
await router.isReady()
|
|
||||||
const wrapper = mount(ComposePage, {
|
|
||||||
global: { plugins: [router] },
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('ABC123')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show plate when no query param', async () => {
|
it('shows error when no plate is provided', async () => {
|
||||||
const router = createTestRouter()
|
const { wrapper } = await mountPage('')
|
||||||
router.push('/compose')
|
expect(wrapper.text()).toContain('Inget registreringsnummer valt')
|
||||||
await router.isReady()
|
})
|
||||||
const wrapper = mount(ComposePage, {
|
|
||||||
global: { plugins: [router] },
|
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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,14 @@ export interface Order {
|
||||||
export function fetchOrders(): Promise<Order[]> {
|
export function fetchOrders(): Promise<Order[]> {
|
||||||
return request<Order[]>('/orders')
|
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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
54
frontend/src/data/templates.ts
Normal file
54
frontend/src/data/templates.ts
Normal 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 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: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -1,14 +1,116 @@
|
||||||
<script setup lang="ts">
|
<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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="compose">
|
<div class="compose">
|
||||||
<h1>Skriv ditt brev</h1>
|
<h1 class="compose__title">Skriv ditt brev</h1>
|
||||||
<p v-if="plate" class="compose__plate">Registreringsnummer: {{ plate }}</p>
|
<p v-if="plate" class="compose__plate">
|
||||||
|
Registreringsnummer: <strong>{{ plate }}</strong>
|
||||||
|
</p>
|
||||||
|
<p v-if="!plate" class="compose__error">
|
||||||
|
Inget registreringsnummer valt.
|
||||||
|
<RouterLink to="/">Gå 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -19,8 +121,177 @@ const plate = (route.query.plate as string) || ''
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose__title {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
.compose__plate {
|
.compose__plate {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
font-size: 0.875rem;
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue