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 { 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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
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">
|
||||
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="/">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>
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue