bilhej/frontend/src/pages/ComposePage.vue
Joakim Mörling 7a95c1423c
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m22s
CI / E2E browser tests (pull_request) Failing after 1m3s
Make customer-facing UI usable on smartphones.
Mobile traffic was breaking on narrow viewports because the header nav
overflowed and several pages used desktop-only spacing. This adds a
shared phone breakpoint, a hamburger menu, and scroll-to-top on route
changes so footer and menu navigation always land at the top of the page.

- Add --page-gutter and max-width 639px rules in base.css
- AppHeader: hamburger panel on small screens; flat account links on mobile
- AppFooter: stack footer links vertically on phones
- Home, compose, edit order, orders, auth, and legal pages: tighter gutters
  and responsive layout (orders card actions stack; home grids single-column)
- Router scrollBehavior: scroll to top on navigation; restore on browser back
- Tests: AppHeader menu toggle, Router scrollBehavior, mobile Playwright checks

Admin page is intentionally unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 13:03:35 +02:00

296 lines
6.9 KiB
Vue

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders'
import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue'
import { RouterLink } from 'vue-router'
const router = useRouter()
const route = useRoute()
const plate = computed(() => (route.query.plate as string) || '')
const letterText = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const showPicker = ref(false)
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. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se'
function handleTemplateSelect(template: LetterTemplate) {
letterText.value = template.body
}
async function handleSubmit() {
if (!canSubmit.value) return
submitting.value = true
errorMessage.value = ''
try {
const order = await createOrder(plate.value, letterText.value)
await router.push({
name: 'payment',
params: { orderId: order.id },
query: { plate: plate.value },
})
} catch {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="compose">
<div v-if="plate" class="compose__layout">
<div class="compose__editor">
<h1 class="compose__title">Skriv ditt brev</h1>
<p class="compose__plate-badge">
<span class="compose__plate-label">Regnr</span>
<span class="compose__plate-value">{{ plate }}</span>
</p>
<form class="compose__form" @submit.prevent="handleSubmit">
<div class="field">
<div class="compose__label-row">
<label for="letter" class="field__label">Ditt meddelande</label>
<button
type="button"
class="compose__templates-btn"
@click="showPicker = true"
>
Visa mallar
</button>
</div>
<textarea
id="letter"
v-model="letterText"
class="field__input compose__textarea"
:maxlength="maxChars"
rows="12"
placeholder="Skriv ditt meddelande här..."
></textarea>
<p
class="field__hint compose__counter"
:class="{ 'compose__counter--warn': charCount > 900 }"
>
{{ charCount }} / {{ maxChars }} tecken
</p>
</div>
<div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn--primary btn--lg compose__submit"
:disabled="!canSubmit"
>
{{ submitting ? 'Skickar...' : 'Fortsätt till betalning' }}
</button>
</form>
</div>
<div class="compose__preview">
<h2 class="compose__preview-title">Förhandsvisning</h2>
<div class="compose__preview-page">
<p class="compose__preview-plate-label">
Registreringsnummer: {{ plate }}
</p>
<p class="compose__preview-body">
{{ letterText }}
</p>
<hr class="compose__preview-divider" />
<p class="compose__preview-footer-text">{{ GDPR_FOOTER }}</p>
</div>
</div>
</div>
<div v-else class="message message--error compose__error">
Inget registreringsnummer valt.
<RouterLink to="/"> tillbaka</RouterLink>
</div>
<TemplatePicker
v-if="showPicker"
@select="handleTemplateSelect"
@close="showPicker = false"
/>
</div>
</template>
<style scoped>
.compose__layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xl);
align-items: start;
max-width: 56rem;
margin: var(--space-2xl) auto 0;
padding: 0 var(--space-lg);
}
.compose__title {
margin: 0 0 var(--space-lg) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.compose__plate-badge {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
background: var(--color-primary-soft);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-full);
margin: 0 0 var(--space-lg) 0;
}
.compose__plate-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-muted);
}
.compose__plate-value {
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-primary-dark);
}
.compose__form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.compose__label-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.compose__templates-btn {
background: var(--color-primary-soft);
border: 1px solid #ddd6fe;
color: var(--color-primary-dark);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
padding: 0.3rem 0.75rem;
border-radius: var(--radius-full);
transition:
background var(--transition-fast),
border-color var(--transition-fast);
}
.compose__templates-btn:hover {
background: #e9d5ff;
border-color: #c4b5fd;
}
.compose__textarea {
resize: vertical;
min-height: 10rem;
font-family: inherit;
}
.compose__counter {
text-align: right;
}
.compose__counter--warn {
color: var(--color-danger) !important;
}
.compose__submit {
width: 100%;
}
.compose__error {
max-width: 28rem;
margin: var(--space-2xl) auto;
}
.compose__preview-body {
white-space: pre-wrap;
}
.compose__preview {
position: sticky;
top: 5rem;
}
.compose__preview-title {
margin: 0 0 var(--space-md) 0;
font-size: 1rem;
color: var(--color-muted);
}
.compose__preview-page {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
box-shadow: var(--shadow-lg);
font-family: var(--font-serif);
font-size: 0.9375rem;
line-height: 1.7;
color: var(--color-ink);
min-height: 20rem;
}
.compose__preview-plate-label {
margin: 0 0 var(--space-lg) 0;
font-family: var(--font-sans);
font-size: 0.8125rem;
color: var(--color-muted);
}
.compose__preview-body {
margin: 0 0 var(--space-lg) 0;
}
.compose__preview-divider {
margin: var(--space-lg) 0;
border: none;
border-top: 1px solid var(--color-border);
}
.compose__preview-footer-text {
margin: 0;
font-size: 0.75rem;
color: var(--color-soft);
font-family: var(--font-sans);
line-height: 1.5;
}
.message a {
color: var(--color-primary);
text-decoration: underline;
}
@media (max-width: 639px) {
.compose__layout {
margin-top: var(--space-xl);
padding-inline: var(--page-gutter);
grid-template-columns: 1fr;
}
.compose__preview {
position: static;
}
}
</style>