refactor: redesign all pages and components with new design system

- Rewrite homepage: practical headline, use-case cards, calm trust note
- Switch from purple to blue brand tokens across all pages
- Replace all CTA buttons with brand-primary, reserve green for success
- Remove emoji from template picker and compose page
- Replace unicode chevrons with SVG expand buttons in admin
- Redesign template picker modal with accessibility semantics
- Add aria-invalid, aria-describedby to form validation
- Add role=status/alert to loading, error, and result messages
- Remove inline styles, replace with scoped utility classes
- Update compose submit text, payment button, order empty state copy
- Remove icon field from letter templates
This commit is contained in:
Joakim Mörling 2026-05-16 16:11:01 +02:00
parent 00327674ed
commit 851cd8afa0
13 changed files with 1318 additions and 1046 deletions

View file

@ -36,7 +36,7 @@ watch(isValid, (valid) => {
<template> <template>
<div class="plate-input"> <div class="plate-input">
<label for="plate" class="plate-input__label">Registreringsnummer</label> <label for="plate" class="field__label">Registreringsnummer</label>
<input <input
id="plate" id="plate"
type="text" type="text"
@ -46,11 +46,13 @@ watch(isValid, (valid) => {
:value="plate" :value="plate"
class="plate-input__field" class="plate-input__field"
:class="{ 'plate-input__field--error': showError }" :class="{ 'plate-input__field--error': showError }"
:aria-invalid="showError"
aria-describedby="plate-error"
placeholder="ABC 123" placeholder="ABC 123"
maxlength="7" maxlength="7"
@input="handleInput" @input="handleInput"
/> />
<p v-if="showError" class="plate-input__error"> <p v-if="showError" id="plate-error" class="field__error">
Ange ett giltigt registreringsnummer Ange ett giltigt registreringsnummer
</p> </p>
</div> </div>
@ -60,47 +62,36 @@ watch(isValid, (valid) => {
.plate-input { .plate-input {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: var(--space-sm);
width: 100%; width: 100%;
} }
.plate-input__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.plate-input__field { .plate-input__field {
width: 100%; width: 100%;
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
font-size: 1.5rem; font-size: 1.5rem;
font-family: monospace; font-family: var(--font-mono);
letter-spacing: 0.15em; letter-spacing: 0.15em;
text-transform: uppercase; text-transform: uppercase;
border: 2px solid #cbd5e0; background: var(--color-surface);
border-radius: 0.5rem; border: 2px solid var(--color-border);
border-radius: var(--radius-md);
outline: none; outline: none;
transition: border-color 0.15s ease; transition:
box-sizing: border-box; border-color var(--transition-fast),
box-shadow var(--transition-fast);
} }
.plate-input__field:focus { .plate-input__field:focus {
border-color: #4299e1; border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25); box-shadow: 0 0 0 3px var(--color-primary-ring);
} }
.plate-input__field--error { .plate-input__field--error {
border-color: #e53e3e; border-color: var(--color-danger);
} }
.plate-input__field--error:focus { .plate-input__field--error:focus {
border-color: #e53e3e; box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.2);
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.25);
}
.plate-input__error {
margin: 0;
font-size: 0.8125rem;
color: #e53e3e;
} }
</style> </style>

View file

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { templates, type LetterTemplate } from '@/data/templates' import { templates, type LetterTemplate } from '@/data/templates'
const emit = defineEmits<{ const emit = defineEmits<{
@ -6,21 +7,59 @@ const emit = defineEmits<{
(e: 'close'): void (e: 'close'): void
}>() }>()
const dialogRef = ref<HTMLDivElement | null>(null)
function handleSelect(template: LetterTemplate) { function handleSelect(template: LetterTemplate) {
emit('select', template) emit('select', template)
emit('close') emit('close')
} }
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
emit('close')
}
}
onMounted(() => {
dialogRef.value?.focus()
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script> </script>
<template> <template>
<div class="modal-overlay" @click.self="emit('close')"> <div class="modal-overlay" @click.self="emit('close')">
<div class="modal"> <div
ref="dialogRef"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
>
<div class="modal__header"> <div class="modal__header">
<h2 class="modal__title">Välj en mall</h2> <h2 id="modal-title" class="modal__title">Välj en mall</h2>
<button class="modal__close" @click="emit('close')">&times;</button> <button class="modal__close" aria-label="Stäng" @click="emit('close')">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div> </div>
<p class="modal__subtitle"> <p class="modal__subtitle">
Klicka en mall för att fylla i meddelandetexten. Välj en starttext. Du kan ändra allt innan du skickar.
</p> </p>
<div class="modal__grid"> <div class="modal__grid">
<button <button
@ -29,7 +68,6 @@ function handleSelect(template: LetterTemplate) {
class="modal__card" class="modal__card"
@click="handleSelect(t)" @click="handleSelect(t)"
> >
<span class="modal__card-icon">{{ t.icon }}</span>
<span class="modal__card-name">{{ t.name }}</span> <span class="modal__card-name">{{ t.name }}</span>
</button> </button>
</div> </div>
@ -46,84 +84,88 @@ function handleSelect(template: LetterTemplate) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
padding: 1rem; padding: var(--space-md);
} }
.modal { .modal {
background: #fff; background: var(--color-surface);
border-radius: 1rem; border-radius: var(--radius-xl);
width: 100%; width: 100%;
max-width: 28rem; max-width: 28rem;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-xl);
outline: none;
} }
.modal__header { .modal__header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1.5rem 1.5rem 0; padding: var(--space-lg) var(--space-lg) 0;
} }
.modal__title { .modal__title {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #1a202c; color: var(--color-ink);
} }
.modal__close { .modal__close {
background: none; background: none;
border: none; border: none;
font-size: 1.5rem; font-size: 1.5rem;
color: #a0aec0; color: var(--color-soft);
cursor: pointer; cursor: pointer;
padding: 0.25rem 0.5rem; padding: 0.25rem;
border-radius: 0.25rem; border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
transition: transition:
color 0.15s, color var(--transition-fast),
background 0.15s; background var(--transition-fast);
} }
.modal__close:hover { .modal__close:hover {
color: #4a5568; color: var(--color-ink);
background: #f7fafc; background: var(--color-border-light);
} }
.modal__subtitle { .modal__subtitle {
margin: 0.5rem 0 0; margin: var(--space-sm) 0 0;
padding: 0 1.5rem; padding: 0 var(--space-lg);
font-size: 0.875rem; font-size: 0.875rem;
color: #718096; color: var(--color-muted);
} }
.modal__grid { .modal__grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.75rem; gap: var(--space-sm);
padding: 1.25rem 1.5rem 1.5rem; padding: var(--space-lg);
} }
.modal__card { .modal__card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; gap: var(--space-sm);
padding: 1.25rem 0.75rem; padding: var(--space-lg) var(--space-sm);
background: #f7fafc; background: var(--color-primary-soft);
border: 2px solid #e2e8f0; border: 2px solid transparent;
border-radius: 0.75rem; border-radius: var(--radius-lg);
cursor: pointer; cursor: pointer;
transition: transition:
border-color 0.15s, border-color var(--transition-fast),
background 0.15s, background var(--transition-fast),
transform 0.1s; transform 0.1s;
font-family: inherit; font-family: inherit;
} }
.modal__card:hover { .modal__card:hover {
border-color: #4299e1; border-color: var(--color-primary);
background: #ebf8ff; background: #dbeafe;
transform: translateY(-1px); transform: translateY(-1px);
} }
@ -131,14 +173,10 @@ function handleSelect(template: LetterTemplate) {
transform: translateY(0); transform: translateY(0);
} }
.modal__card-icon {
font-size: 2rem;
}
.modal__card-name { .modal__card-name {
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 600; font-weight: 600;
color: #4a5568; color: var(--color-ink);
text-align: center; text-align: center;
line-height: 1.3; line-height: 1.3;
} }

View file

@ -16,50 +16,62 @@ defineProps<{
<template> <template>
<div class="vehicle-info"> <div class="vehicle-info">
<div v-if="vehicle" class="vehicle-info__card"> <div
<p class="vehicle-info__card-text"> v-if="vehicle"
class="vehicle-info__card vehicle-info__card--found"
role="status"
>
<p class="vehicle-info__text">
{{ vehicle.make }} {{ vehicle.model }} ({{ vehicle.year }}) &mdash; {{ vehicle.make }} {{ vehicle.model }} ({{ vehicle.year }}) &mdash;
{{ vehicle.color }} {{ vehicle.color }}
</p> </p>
</div> </div>
<div v-else-if="loading" class="vehicle-info__loading">Söker...</div> <div v-else-if="loading" class="vehicle-info__loading" role="status">
<div v-else-if="notFound" class="vehicle-info__not-found"> Söker...
<p>Inget fordon hittades</p> </div>
<div
v-else-if="notFound"
class="vehicle-info__card vehicle-info__card--missing"
role="status"
>
<p class="vehicle-info__text">Inget fordon hittades</p>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.vehicle-info { .vehicle-info {
margin-top: 0.75rem; margin-top: var(--space-md);
} }
.vehicle-info__loading { .vehicle-info__loading {
color: #718096; color: var(--color-muted);
font-size: 0.875rem; font-size: 0.875rem;
} }
.vehicle-info__card { .vehicle-info__card {
padding: 1rem; padding: var(--space-md);
background: #f0fff4; border-radius: var(--radius-md);
border: 1px solid #c6f6d5; font-size: 0.875rem;
border-radius: 0.5rem;
} }
.vehicle-info__card-text { .vehicle-info__card--found {
background: var(--color-success-soft);
border: 1px solid #bbf7d0;
}
.vehicle-info__card--missing {
background: var(--color-warning-soft);
border: 1px solid #fde68a;
}
.vehicle-info__text {
margin: 0; margin: 0;
font-weight: 500; font-weight: 500;
color: var(--color-ink);
} }
.vehicle-info__not-found { .vehicle-info__card--missing .vehicle-info__text {
padding: 1rem; color: var(--color-warning);
background: #fffaf0;
border: 1px solid #feebc8;
border-radius: 0.5rem;
}
.vehicle-info__not-found p {
margin: 0;
color: #c05621;
} }
</style> </style>

View file

@ -1,13 +1,11 @@
export interface LetterTemplate { export interface LetterTemplate {
name: string name: string
icon: string
body: string body: string
} }
export const templates: LetterTemplate[] = [ export const templates: LetterTemplate[] = [
{ {
name: 'Komplimang', name: 'Komplimang',
icon: '🌟',
body: `Hej! 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 bra. 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 bra.
@ -16,7 +14,6 @@ Ha en trevlig dag!`,
}, },
{ {
name: 'Köpförfrågan', name: 'Köpförfrågan',
icon: '🚗',
body: `Hej! body: `Hej!
Jag är intresserad av att köpa din bil. Om du någon gång funderar att sälja den, får du gärna höra av dig. Jag är intresserad av att köpa din bil. Om du någon gång funderar att sälja den, får du gärna höra av dig.
@ -28,7 +25,6 @@ Vänliga hälsningar,
}, },
{ {
name: 'Tips / servicebehov', name: 'Tips / servicebehov',
icon: '🔧',
body: `Hej! 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 snart som möjligt. 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 snart som möjligt.
@ -37,7 +33,6 @@ Hoppas detta var till hjälp!`,
}, },
{ {
name: 'Körbeteende', name: 'Körbeteende',
icon: '🛣️',
body: `Hej! body: `Hej!
Jag ville uppmärksamma dig en situation i trafiken där jag reagerade ditt körbeteende. Jag menar inget illa utan vill bara ge en vänlig påminnelse om att vara extra uppmärksam. Jag ville uppmärksamma dig en situation i trafiken där jag reagerade ditt körbeteende. Jag menar inget illa utan vill bara ge en vänlig påminnelse om att vara extra uppmärksam.
@ -46,7 +41,6 @@ Tack för att du lyssnar!`,
}, },
{ {
name: 'Tuta / frustration', name: 'Tuta / frustration',
icon: '📢',
body: `Hej! 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 ibland, men jag ville ut för att lösa det ett trevligt sätt. Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir ibland, men jag ville ut för att lösa det ett trevligt sätt.
@ -55,7 +49,6 @@ Ha det bra!`,
}, },
{ {
name: 'Mindre parkeringsskada', name: 'Mindre parkeringsskada',
icon: '🅿️',
body: `Hej! body: `Hej!
Jag råkade skada din bil lite när jag parkerade. Det var inte meningen och jag ber om ursäkt. Jag vill gärna att vi löser det här tillsammans. Jag råkade skada din bil lite när jag parkerade. Det var inte meningen och jag ber om ursäkt. Jag vill gärna att vi löser det här tillsammans.
@ -67,7 +60,6 @@ Vänliga hälsningar,
}, },
{ {
name: 'Fritt meddelande', name: 'Fritt meddelande',
icon: '✏️',
body: '', body: '',
}, },
] ]

View file

@ -1,19 +1,39 @@
<script setup lang="ts"></script> <script setup lang="ts"></script>
<template> <template>
<div class="about"> <div class="page">
<h1>Om BilHälsning</h1> <div class="page__card">
<h1>Om Bilhej</h1>
<p> <p>
BilHälsning är en tjänst som låter dig skicka fysiska brev till Bilhej är en tjänst som låter dig skicka fysiska brev till fordonsägare
fordonsägare via registreringsnummer. via registreringsnummer.
</p> </p>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.about { .page {
max-width: 28rem; max-width: 36rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
}
.page__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
h1 {
margin: 0 0 var(--space-md) 0;
font-size: 1.5rem;
}
p {
margin: 0;
line-height: 1.7;
} }
</style> </style>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { import {
fetchAllOrders, fetchAllOrders,
updateOrderStatus, updateOrderStatus,
@ -24,13 +24,13 @@ const statusLabels: Record<string, string> = {
failed: 'Misslyckad', failed: 'Misslyckad',
} }
const statusClasses: Record<string, string> = { const statusBadge: Record<string, string> = {
pending_payment: 'badge--gray', pending_payment: 'badge--muted',
paid: 'badge--blue', paid: 'badge--primary',
lookup_started: 'badge--blue', lookup_started: 'badge--primary',
sent: 'badge--green', sent: 'badge--success',
delivered: 'badge--green', delivered: 'badge--success',
failed: 'badge--red', failed: 'badge--danger',
} }
const allStatuses = [ const allStatuses = [
@ -42,6 +42,20 @@ const allStatuses = [
'failed', 'failed',
] ]
const stats = computed(() => {
const total = orders.value.length
const paid = orders.value.filter((o) =>
['paid', 'lookup_started', 'sent', 'delivered'].includes(o.status),
).length
const pending = orders.value.filter(
(o) => o.status === 'pending_payment',
).length
const sent = orders.value.filter(
(o) => o.status === 'sent' || o.status === 'delivered',
).length
return { total, paid, pending, sent }
})
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', { return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric', year: 'numeric',
@ -107,28 +121,53 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="admin-dashboard"> <div class="admin">
<h1 class="admin-dashboard__title">Administration</h1> <h1 class="admin__title">Administration</h1>
<p class="admin-dashboard__subtitle">
Hantera beställningar, mallar och användare.
</p>
<p v-if="loading" class="admin-dashboard__loading"> <p
v-if="loading"
class="text-muted text-center admin__loading"
role="status"
>
Laddar beställningar... Laddar beställningar...
</p> </p>
<p v-else-if="error" class="admin-dashboard__error">{{ error }}</p> <div v-else-if="error" class="message message--error">{{ error }}</div>
<p v-else-if="orders.length === 0" class="admin-dashboard__empty"> <div v-else-if="orders.length === 0" class="message message--info">
Inga beställningar ännu. Inga beställningar ännu.
</p> </div>
<div v-else class="admin-dashboard__table-wrapper"> <template v-else>
<p v-if="statusError" class="admin-dashboard__status-error"> <div class="admin__stats">
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.total }}</span>
<span class="admin__stat-label">Totalt</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.paid }}</span>
<span class="admin__stat-label">Betalda</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.pending }}</span>
<span class="admin__stat-label">Väntar</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.sent }}</span>
<span class="admin__stat-label">Skickade</span>
</div>
</div>
<p
v-if="statusError"
class="message message--error admin__status-error"
role="alert"
>
{{ statusError }} {{ statusError }}
</p> </p>
<table class="admin-dashboard__table"> <div class="admin__table-wrap">
<table class="admin__table">
<thead> <thead>
<tr> <tr>
<th>Datum</th> <th>Datum</th>
@ -141,19 +180,18 @@ onMounted(async () => {
<tbody> <tbody>
<template v-for="order in orders" :key="order.id"> <template v-for="order in orders" :key="order.id">
<tr <tr
class="admin-dashboard__row" class="admin__row"
:class="{ :class="{
'admin-dashboard__row--expanded': expandedOrderId === order.id, 'admin__row--expanded': expandedOrderId === order.id,
}" }"
@click="toggleExpand(order.id)"
> >
<td>{{ formatDate(order.createdAt) }}</td> <td>{{ formatDate(order.createdAt) }}</td>
<td>{{ order.email }}</td> <td>{{ order.email }}</td>
<td class="admin-dashboard__plate">{{ order.plate }}</td> <td class="admin__plate">{{ order.plate }}</td>
<td> <td>
<select <select
class="admin-dashboard__status-select" class="admin__status-select"
:class="statusClasses[order.status] || 'badge--gray'" :class="statusBadge[order.status] || 'badge--muted'"
:value="order.status" :value="order.status"
@change=" @change="
handleStatusChange( handleStatusChange(
@ -168,51 +206,89 @@ onMounted(async () => {
</option> </option>
</select> </select>
</td> </td>
<td class="admin-dashboard__expand"> <td class="admin__chevron-cell">
<span class="admin-dashboard__chevron"> <button
{{ expandedOrderId === order.id ? '▼' : '▶' }} class="admin__expand-btn"
</span> :aria-expanded="expandedOrderId === order.id"
:aria-label="
expandedOrderId === order.id
? 'Dölj detaljer'
: 'Visa detaljer'
"
@click.stop="toggleExpand(order.id)"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline
:points="
expandedOrderId === order.id
? '6 9 12 15 18 9'
: '9 6 15 12 9 18'
"
/>
</svg>
</button>
</td> </td>
</tr> </tr>
<tr <tr
v-if="expandedOrderId === order.id" v-if="expandedOrderId === order.id"
class="admin-dashboard__expanded-row" class="admin__expanded-row"
> >
<td :colspan="5"> <td :colspan="5">
<div class="admin-dashboard__letter"> <div class="admin__expanded-inner">
<div class="admin-dashboard__letter-label">Brevtext</div> <div class="admin__section">
<div class="admin-dashboard__letter-text"> <div class="admin__section-label">Brevtext</div>
<div class="admin__section-body">
{{ order.letterText }} {{ order.letterText }}
</div> </div>
</div> </div>
<div class="admin-dashboard__tracking"> <div class="admin__section">
<div class="admin-dashboard__tracking-header"> <div class="admin__section-header">
<span class="admin-dashboard__tracking-label" <span class="admin__section-label">Spårnings-ID</span>
>Spårnings-ID</span
>
<a <a
v-if="order.trackingId" v-if="order.trackingId"
class="admin-dashboard__tracking-link" class="admin__tracking-link"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`" :href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@click.stop @click.stop
> >
Spåra hos PostNord Spåra hos PostNord
</a> </a>
</div> </div>
<p v-if="trackingError" class="admin-dashboard__status-error"> <p
v-if="trackingError"
class="message message--error admin__tracking-error"
role="alert"
>
{{ trackingError }} {{ trackingError }}
</p> </p>
<div class="admin-dashboard__tracking-input-row"> <div class="admin__tracking-row">
<label
:for="`tracking-${order.id}`"
class="visually-hidden"
>Spårnings-ID</label
>
<input <input
class="admin-dashboard__tracking-input" :id="`tracking-${order.id}`"
class="admin__tracking-input"
type="text" type="text"
:value=" :value="
trackingInputValues[order.id] ?? order.trackingId ?? '' trackingInputValues[order.id] ??
order.trackingId ??
''
" "
placeholder="PN..." placeholder="PN..."
@input=" @input="
@ -223,245 +299,261 @@ onMounted(async () => {
@click.stop @click.stop
/> />
<button <button
class="admin-dashboard__tracking-save" class="btn btn--primary btn--sm"
@click.stop="handleTrackingSave(order.id)" @click.stop="handleTrackingSave(order.id)"
> >
Spara spårning Spara
</button> </button>
</div> </div>
</div> </div>
</div>
</td> </td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
</table> </table>
</div> </div>
</template>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.admin-dashboard { .admin {
max-width: 64rem; max-width: 72rem;
margin: 3rem auto 0; margin: var(--space-2xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.admin-dashboard__title { .admin__title {
margin: 0 0 0.25rem 0; margin: 0 0 var(--space-xl) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.admin-dashboard__subtitle { .admin__stats {
margin: 0 0 1.5rem 0; display: grid;
color: #718096; grid-template-columns: repeat(4, 1fr);
font-size: 0.875rem; gap: var(--space-md);
margin-bottom: var(--space-xl);
} }
.admin-dashboard__loading, .admin__stat {
.admin-dashboard__error, background: var(--color-surface);
.admin-dashboard__empty { border: 1px solid var(--color-border);
margin: 2rem 0; border-radius: var(--radius-lg);
padding: 1rem; padding: var(--space-md) var(--space-lg);
border-radius: 0.5rem;
font-size: 0.875rem;
text-align: center; text-align: center;
box-shadow: var(--shadow-sm);
} }
.admin-dashboard__loading { .admin__stat-value {
color: #718096; display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-ink);
} }
.admin-dashboard__error { .admin__stat-label {
background: #fff5f5; font-size: 0.75rem;
border: 1px solid #fed7d7; font-weight: 600;
color: #c53030; text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
margin-top: var(--space-xs);
} }
.admin-dashboard__empty { .admin__table-wrap {
background: #f7fafc; background: var(--color-surface);
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
color: #718096; border-radius: var(--radius-lg);
} overflow: hidden;
box-shadow: var(--shadow-card);
.admin-dashboard__status-error {
margin: 0 0 0.75rem 0;
padding: 0.5rem 0.75rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.375rem;
color: #c53030;
font-size: 0.8125rem;
}
.admin-dashboard__table-wrapper {
overflow-x: auto; overflow-x: auto;
} }
.admin-dashboard__table { .admin__table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.875rem; font-size: 0.875rem;
} }
.admin-dashboard__table thead { .admin__table thead {
background: #f7fafc; background: var(--color-border-light);
} }
.admin-dashboard__table th { .admin__table th {
padding: 0.75rem 1rem; padding: 0.75rem var(--space-md);
text-align: left; text-align: left;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: #718096; color: var(--color-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
border-bottom: 2px solid #e2e8f0; border-bottom: 1px solid var(--color-border);
} }
.admin-dashboard__row { .admin__row {
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid var(--color-border-light);
transition: background 0.1s; transition: background var(--transition-fast);
} }
.admin-dashboard__row:hover { .admin__row:last-child {
background: #f7fafc; border-bottom: none;
} }
.admin-dashboard__row--expanded { .admin__row:hover {
background: #ebf8ff; background: var(--color-border-light);
} }
.admin-dashboard__row td { .admin__row--expanded {
padding: 0.75rem 1rem; background: var(--color-primary-soft) !important;
color: #4a5568; }
.admin__row td {
padding: 0.75rem var(--space-md);
color: var(--color-ink);
white-space: nowrap; white-space: nowrap;
} }
.admin-dashboard__plate { .admin__plate {
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: #1a202c !important;
} }
.admin-dashboard__status-select { .admin__status-select {
display: inline-block; padding: 0.2rem 0.5rem;
padding: 0.25rem 0.5rem; border-radius: var(--radius-sm);
border-radius: 0.375rem; border: 1px solid var(--color-border);
border: 1px solid #e2e8f0;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: #4a5568; color: var(--color-ink);
outline: none; outline: none;
cursor: pointer; cursor: pointer;
background: #fff; background: var(--color-surface);
} }
.admin-dashboard__status-select:focus { .admin__status-select:focus {
border-color: #4299e1; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2); box-shadow: 0 0 0 2px var(--color-primary-ring);
} }
.admin-dashboard__expand { .admin__chevron-cell {
text-align: center; text-align: center;
width: 2rem; width: 2rem;
} }
.admin-dashboard__chevron { .admin__expand-btn {
font-size: 0.625rem; background: none;
color: #a0aec0; border: none;
color: var(--color-soft);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color var(--transition-fast),
background var(--transition-fast);
} }
.admin-dashboard__expanded-row td { .admin__expand-btn:hover {
color: var(--color-ink);
background: var(--color-border-light);
}
.admin__expanded-row td {
padding: 0; padding: 0;
background: #f7fafc; background: var(--color-surface);
} }
.admin-dashboard__letter { .admin__expanded-inner {
padding: 1rem 1.25rem; padding: var(--space-lg);
border-top: 1px solid #e2e8f0; display: flex;
flex-direction: column;
gap: var(--space-lg);
border-top: 1px solid var(--color-border-light);
} }
.admin-dashboard__letter-label { .admin__section {
padding: var(--space-md);
background: var(--color-border-light);
border-radius: var(--radius-md);
}
.admin__section-label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: #a0aec0; color: var(--color-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin-bottom: 0.5rem; margin-bottom: var(--space-sm);
} }
.admin-dashboard__letter-text { .admin__section-body {
font-size: 0.875rem; font-size: 0.875rem;
color: #4a5568; color: var(--color-ink);
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
} }
.admin-dashboard__tracking { .admin__section-header {
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
}
.admin-dashboard__tracking-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 0.5rem;
} }
.admin-dashboard__tracking-label { .admin__tracking-link {
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.admin-dashboard__tracking-link {
font-size: 0.8125rem; font-size: 0.8125rem;
color: #4299e1; color: var(--color-primary);
text-decoration: none; text-decoration: none;
font-weight: 500;
} }
.admin-dashboard__tracking-link:hover { .admin__tracking-link:hover {
text-decoration: underline; text-decoration: underline;
} }
.admin-dashboard__tracking-input-row { .admin__tracking-row {
display: flex; display: flex;
gap: 0.5rem; gap: var(--space-sm);
margin-top: var(--space-sm);
} }
.admin-dashboard__tracking-input { .admin__tracking-input {
flex: 1; flex: 1;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
border-radius: 0.375rem; border-radius: var(--radius-sm);
font-size: 0.8125rem; font-size: 0.8125rem;
color: #4a5568; color: var(--color-ink);
outline: none; outline: none;
transition: border-color var(--transition-fast);
} }
.admin-dashboard__tracking-input:focus { .admin__tracking-input:focus {
border-color: #4299e1; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2); box-shadow: 0 0 0 2px var(--color-primary-ring);
} }
.admin-dashboard__tracking-save { .admin__loading {
padding: 0.5rem 1rem; padding: var(--space-2xl) 0;
border: none; }
border-radius: 0.375rem;
background: #4299e1; .admin__status-error {
color: #fff; margin-bottom: var(--space-md);
}
.admin__tracking-error {
margin-bottom: var(--space-sm);
padding: var(--space-sm) var(--space-md);
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
} }
.admin-dashboard__tracking-save:hover { @media (max-width: 768px) {
background: #3182ce; .admin__stats {
grid-template-columns: repeat(2, 1fr);
}
} }
</style> </style>

View file

@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders' import { createOrder } from '@/api/orders'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
import { RouterLink } from 'vue-router'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -21,7 +22,7 @@ const canSubmit = computed(
) )
const GDPR_FOOTER = 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' 'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se'
function handleTemplateSelect(template: LetterTemplate) { function handleTemplateSelect(template: LetterTemplate) {
letterText.value = template.body letterText.value = template.body
@ -50,61 +51,75 @@ async function handleSubmit() {
<template> <template>
<div class="compose"> <div class="compose">
<div v-if="plate" class="compose__layout">
<div class="compose__editor">
<h1 class="compose__title">Skriv ditt brev</h1> <h1 class="compose__title">Skriv ditt brev</h1>
<p v-if="plate" class="compose__plate"> <p class="compose__plate-badge">
Registreringsnummer: <strong>{{ plate }}</strong> <span class="compose__plate-label">Regnr</span>
</p> <span class="compose__plate-value">{{ plate }}</span>
<p v-if="!plate" class="compose__error">
Inget registreringsnummer valt.
<RouterLink to="/"> tillbaka</RouterLink>
</p> </p>
<form v-if="plate" class="compose__form" @submit.prevent="handleSubmit"> <form class="compose__form" @submit.prevent="handleSubmit">
<div class="compose__field"> <div class="field">
<div class="compose__label-row"> <div class="compose__label-row">
<label for="letter" class="compose__label">Ditt meddelande</label> <label for="letter" class="field__label">Ditt meddelande</label>
<button <button
type="button" type="button"
class="compose__templates-btn" class="compose__templates-btn"
@click="showPicker = true" @click="showPicker = true"
> >
Visa mallar Visa mallar
</button> </button>
</div> </div>
<textarea <textarea
id="letter" id="letter"
v-model="letterText" v-model="letterText"
class="compose__textarea" class="field__input compose__textarea"
:maxlength="maxChars" :maxlength="maxChars"
rows="10" rows="12"
placeholder="Skriv ditt meddelande här..." placeholder="Skriv ditt meddelande här..."
></textarea> ></textarea>
<p <p
class="compose__counter" class="field__hint compose__counter"
:class="{ 'compose__counter--warn': charCount > 900 }" :class="{ 'compose__counter--warn': charCount > 900 }"
> >
{{ charCount }} / {{ maxChars }} tecken {{ charCount }} / {{ maxChars }} tecken
</p> </p>
</div> </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"> <div class="compose__preview">
<h2 class="compose__preview-title">Förhandsvisning</h2> <h2 class="compose__preview-title">Förhandsvisning</h2>
<div class="compose__preview-page"> <div class="compose__preview-page">
<p class="compose__preview-plate">Registreringsnummer: {{ plate }}</p> <p class="compose__preview-plate-label">
<p class="compose__preview-body" style="white-space: pre-wrap"> Registreringsnummer: {{ plate }}
</p>
<p class="compose__preview-body">
{{ letterText }} {{ letterText }}
</p> </p>
<hr class="compose__preview-divider" /> <hr class="compose__preview-divider" />
<p class="compose__preview-footer">{{ GDPR_FOOTER }}</p> <p class="compose__preview-footer-text">{{ GDPR_FOOTER }}</p>
</div>
</div> </div>
</div> </div>
<p v-if="errorMessage" class="compose__api-error">{{ errorMessage }}</p> <div v-else class="message message--error compose__error">
Inget registreringsnummer valt.
<button type="submit" class="compose__submit" :disabled="!canSubmit"> <RouterLink to="/"> tillbaka</RouterLink>
{{ submitting ? 'Skickar...' : 'Skicka brev (49 kr)' }} </div>
</button>
</form>
<TemplatePicker <TemplatePicker
v-if="showPicker" v-if="showPicker"
@ -115,59 +130,50 @@ async function handleSubmit() {
</template> </template>
<style scoped> <style scoped>
.compose { .compose__layout {
max-width: 28rem; display: grid;
margin: 3rem auto 0; grid-template-columns: 1fr 1fr;
padding: 0 1rem; gap: var(--space-xl);
align-items: start;
max-width: 56rem;
margin: var(--space-2xl) auto 0;
padding: 0 var(--space-lg);
} }
.compose__title { .compose__title {
margin: 0 0 0.25rem 0; margin: 0 0 var(--space-lg) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.compose__plate { .compose__plate-badge {
margin: 0 0 1.5rem 0; display: inline-flex;
color: #4a5568; align-items: center;
font-size: 0.875rem; 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__error { .compose__plate-label {
margin: 2rem 0; font-size: 0.75rem;
padding: 1rem; font-weight: 600;
background: #fff5f5; text-transform: uppercase;
border: 1px solid #fed7d7; color: var(--color-muted);
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
} }
.compose__error a { .compose__plate-value {
color: #4299e1; font-size: 0.9375rem;
text-decoration: none; font-weight: 700;
} letter-spacing: 0.08em;
color: var(--color-primary-dark);
.compose__error a:hover {
text-decoration: underline;
} }
.compose__form { .compose__form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: var(--space-md);
}
.compose__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.compose__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
} }
.compose__label-row { .compose__label-row {
@ -177,129 +183,112 @@ async function handleSubmit() {
} }
.compose__templates-btn { .compose__templates-btn {
background: #ebf8ff; background: var(--color-primary-soft);
border: 1px solid #bee3f8; border: 1px solid #ddd6fe;
color: #2b6cb0; color: var(--color-primary-dark);
font-size: 0.8125rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
padding: 0.375rem 0.875rem; padding: 0.3rem 0.75rem;
border-radius: 9999px; border-radius: var(--radius-full);
transition: transition:
background 0.15s, background var(--transition-fast),
border-color 0.15s; border-color var(--transition-fast);
} }
.compose__templates-btn:hover { .compose__templates-btn:hover {
background: #bee3f8; background: #e9d5ff;
border-color: #90cdf4; border-color: #c4b5fd;
} }
.compose__textarea { .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; resize: vertical;
transition: border-color 0.15s ease; min-height: 10rem;
box-sizing: border-box; font-family: inherit;
}
.compose__textarea:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
} }
.compose__counter { .compose__counter {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
text-align: right; text-align: right;
} }
.compose__counter--warn { .compose__counter--warn {
color: #e53e3e; color: var(--color-danger) !important;
}
.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 { .compose__submit {
width: 100%; width: 100%;
padding: 0.875rem 1.5rem; }
background: #38a169;
color: #fff; .compose__error {
border: none; max-width: 28rem;
border-radius: 0.5rem; 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; font-size: 1rem;
font-weight: 600; color: var(--color-muted);
cursor: pointer;
transition: background 0.15s ease;
} }
.compose__submit:hover:not(:disabled) { .compose__preview-page {
background: #2f855a; 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__submit:disabled { .compose__preview-plate-label {
opacity: 0.5; margin: 0 0 var(--space-lg) 0;
cursor: not-allowed; 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: 768px) {
.compose__layout {
grid-template-columns: 1fr;
}
.compose__preview {
position: static;
}
} }
</style> </style>

View file

@ -1,19 +1,39 @@
<script setup lang="ts"></script> <script setup lang="ts"></script>
<template> <template>
<div class="contact"> <div class="page">
<div class="page__card">
<h1>Kontakta oss</h1> <h1>Kontakta oss</h1>
<p> <p>
Har du frågor eller feedback? Hör av dig till oss Har du frågor eller feedback? Hör av dig till oss
<a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>. <a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>.
</p> </p>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.contact { .page {
max-width: 28rem; max-width: 36rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
}
.page__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
h1 {
margin: 0 0 var(--space-md) 0;
font-size: 1.5rem;
}
p {
margin: 0;
line-height: 1.7;
} }
</style> </style>

View file

@ -37,8 +37,17 @@ function handleLookup(lookedUpPlate: string) {
<template> <template>
<div class="home"> <div class="home">
<p class="home__subtitle">Skicka ett brev till en fordonsägare</p> <section class="home__hero">
<div class="home__hero-content">
<p class="home__eyebrow">Brev via registreringsnummer</p>
<h1 class="home__headline">Skicka ett brev<br />till en bilägare</h1>
<p class="home__lead">
Skriv ett respektfullt meddelande, ange registreringsnumret och låt
Bilhej posta brevet åt dig.
</p>
</div>
<div class="home__card">
<PlateInput v-model="plate" @lookup="handleLookup" /> <PlateInput v-model="plate" @lookup="handleLookup" />
<VehicleInfo <VehicleInfo
@ -51,39 +60,266 @@ function handleLookup(lookedUpPlate: string) {
<RouterLink <RouterLink
v-if="vehicle" v-if="vehicle"
:to="{ name: 'compose', query: { plate } }" :to="{ name: 'compose', query: { plate } }"
class="home__cta" class="btn btn--primary btn--lg home__cta"
> >
Skicka ett brev till ägaren Fortsätt till brevet
</RouterLink> </RouterLink>
</div> </div>
</section>
<section class="home__uses">
<div class="home__uses-inner">
<h2 class="home__uses-title">Vanliga anledningar</h2>
<div class="home__uses-grid">
<div class="home__use">
<h3>Vill köpa bilen</h3>
<p>Skicka en förfrågan utan att leta efter ägaren själv.</p>
</div>
<div class="home__use">
<h3>Tipsa om något</h3>
<p>
Berätta om lampor, däck eller annat som ägaren kan vilja veta.
</p>
</div>
<div class="home__use">
<h3>Skicka en komplimang</h3>
<p>En enkel hälsning till någon som tar hand om sin bil.</p>
</div>
</div>
</div>
</section>
<section class="home__steps">
<div class="home__steps-inner">
<h2 class="home__steps-title"> fungerar det</h2>
<div class="home__steps-grid">
<div class="home__step">
<span class="home__step-number">1</span>
<h3>Ange registreringsnummer</h3>
<p>
Vi visar grundläggande fordonsinformation att du vet att det är
rätt bil.
</p>
</div>
<div class="home__step">
<span class="home__step-number">2</span>
<h3>Skriv meddelandet</h3>
<p>
Utgå från en mall eller skriv själv. Du ser brevet innan du
skickar.
</p>
</div>
<div class="home__step">
<span class="home__step-number">3</span>
<h3>Vi postar brevet</h3>
<p>
Efter betalning hanteras brevet manuellt och skickas med post.
</p>
</div>
</div>
</div>
</section>
<section class="home__trust">
<div class="home__trust-inner">
<p class="home__trust-text">
<strong>Trygg hantering.</strong> Bilhej hanterar adressuppgifter
endast för att kunna posta brevet. Mottagarens adress visas inte i
tjänsten.
</p>
</div>
</section>
</div>
</template> </template>
<style scoped> <style scoped>
.home { .home__hero {
max-width: 28rem; display: grid;
margin: 3rem auto 0; grid-template-columns: 1fr 1fr;
padding: 0 1rem; gap: var(--space-3xl);
align-items: center;
max-width: 72rem;
margin: 0 auto;
padding: var(--space-3xl) var(--space-lg);
} }
.home__subtitle { .home__eyebrow {
color: #718096; display: inline-block;
margin: 0 0 1.5rem 0; font-size: 0.875rem;
font-weight: 500;
color: var(--color-primary);
margin: 0 0 var(--space-md) 0;
}
.home__headline {
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
line-height: 1.15;
color: var(--color-ink);
margin: 0 0 var(--space-lg) 0;
}
.home__lead {
font-size: 1.0625rem;
color: var(--color-muted);
line-height: 1.7;
margin: 0;
max-width: 30rem;
}
.home__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-lg);
} }
.home__cta { .home__cta {
display: block; display: flex;
margin-top: 1.5rem; margin-top: var(--space-lg);
padding: 0.875rem 1.5rem;
background: #38a169;
color: #fff;
text-align: center;
text-decoration: none;
font-weight: 600;
border-radius: 0.5rem;
font-size: 1rem;
} }
.home__cta:hover { .home__uses {
background: #2f855a; padding: var(--space-3xl) var(--space-lg);
}
.home__uses-inner {
max-width: 72rem;
margin: 0 auto;
}
.home__uses-title {
font-size: 1.375rem;
font-weight: 700;
text-align: center;
margin: 0 0 var(--space-xl) 0;
color: var(--color-ink);
}
.home__uses-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
.home__use {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
box-shadow: var(--shadow-sm);
}
.home__use h3 {
margin: 0 0 var(--space-sm) 0;
font-size: 1rem;
color: var(--color-ink);
}
.home__use p {
margin: 0;
font-size: 0.875rem;
color: var(--color-muted);
line-height: 1.6;
}
.home__steps {
padding: var(--space-3xl) var(--space-lg);
}
.home__steps-inner {
max-width: 72rem;
margin: 0 auto;
}
.home__steps-title {
font-size: 1.375rem;
font-weight: 700;
text-align: center;
margin: 0 0 var(--space-xl) 0;
color: var(--color-ink);
}
.home__steps-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
.home__step {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
text-align: center;
box-shadow: var(--shadow-sm);
}
.home__step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 700;
font-size: 1rem;
border-radius: var(--radius-full);
margin-bottom: var(--space-md);
}
.home__step h3 {
margin: 0 0 var(--space-sm) 0;
font-size: 1rem;
color: var(--color-ink);
}
.home__step p {
margin: 0;
font-size: 0.875rem;
color: var(--color-muted);
line-height: 1.6;
}
.home__trust {
padding: 0 var(--space-lg) var(--space-3xl);
}
.home__trust-inner {
max-width: 48rem;
margin: 0 auto;
padding: var(--space-lg) var(--space-xl);
background: var(--color-primary-soft);
border-radius: var(--radius-lg);
text-align: center;
}
.home__trust-text {
margin: 0;
font-size: 0.9375rem;
color: var(--color-primary-dark);
line-height: 1.7;
}
@media (max-width: 768px) {
.home__hero {
grid-template-columns: 1fr;
gap: var(--space-xl);
padding: var(--space-xl) var(--space-lg);
}
.home__headline {
font-size: clamp(1.75rem, 6vw, 2.5rem);
}
.home__uses-grid {
grid-template-columns: 1fr;
}
.home__steps-grid {
grid-template-columns: 1fr;
}
} }
</style> </style>

View file

@ -35,153 +35,107 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<div class="login"> <div class="page">
<h1 class="login__title">Logga in</h1> <div class="page__card">
<p class="login__subtitle"> <h1 class="page__title">Logga in</h1>
<p class="page__subtitle">
Ange din e-postadress och ditt lösenord för att logga in. Ange din e-postadress och ditt lösenord för att logga in.
</p> </p>
<form class="login__form" @submit.prevent="handleSubmit"> <form
<div class="login__field"> class="page__form"
<label for="email" class="login__label">E-postadress</label> method="post"
action="/api/auth/login"
@submit.prevent="handleSubmit"
>
<div class="field">
<label for="email" class="field__label">E-postadress</label>
<input <input
id="email" id="email"
v-model="email" v-model="email"
type="email" type="email"
name="email"
autocomplete="email" autocomplete="email"
class="login__input" class="field__input"
placeholder="namn@exempel.se" placeholder="namn@exempel.se"
/> />
</div> </div>
<div class="login__field"> <div class="field">
<label for="password" class="login__label">Lösenord</label> <label for="password" class="field__label">Lösenord</label>
<input <input
id="password" id="password"
v-model="password" v-model="password"
type="password" type="password"
name="password"
autocomplete="current-password" autocomplete="current-password"
class="login__input" class="field__input"
placeholder="Ditt lösenord" placeholder="Ditt lösenord"
/> />
</div> </div>
<p v-if="errorMessage" class="login__api-error">{{ errorMessage }}</p> <div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
</div>
<button <button
type="submit" type="submit"
class="login__submit" class="btn btn--primary btn--lg login__submit"
:disabled="!isValid || submitting" :disabled="!isValid || submitting"
> >
{{ submitting ? 'Loggar in...' : 'Logga in' }} {{ submitting ? 'Loggar in...' : 'Logga in' }}
</button> </button>
</form> </form>
<p class="login__register-link"> <p class="page__footer-link">
Har du inget konto? Har du inget konto?
<RouterLink to="/registrera">Skapa konto</RouterLink> <RouterLink to="/registrera">Skapa konto</RouterLink>
</p> </p>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.login { .page {
max-width: 28rem; max-width: 28rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.login__title { .page__card {
margin: 0 0 0.25rem 0; background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.login__subtitle { .page__subtitle {
margin: 0 0 1.5rem 0; margin: 0 0 var(--space-xl) 0;
color: #718096;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.login__form { .page__form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--space-md);
} }
.login__field { .page__footer-link {
display: flex; margin-top: var(--space-lg);
flex-direction: column; text-align: center;
gap: 0.375rem;
}
.login__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.login__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.login__input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.login__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.login__submit { .login__submit {
width: 100%; width: 100%;
padding: 0.875rem 1.5rem;
background: #4299e1;
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.login__submit:hover:not(:disabled) {
background: #3182ce;
}
.login__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login__register-link {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: #718096;
}
.login__register-link a {
color: #4299e1;
text-decoration: none;
}
.login__register-link a:hover {
text-decoration: underline;
} }
</style> </style>

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { fetchOrders, type Order } from '@/api/orders' import { fetchOrders, type Order } from '@/api/orders'
import { RouterLink } from 'vue-router'
const orders = ref<Order[]>([]) const orders = ref<Order[]>([])
const loading = ref(true) const loading = ref(true)
@ -15,13 +16,13 @@ const statusLabels: Record<string, string> = {
failed: 'Misslyckad', failed: 'Misslyckad',
} }
const statusClasses: Record<string, string> = { const statusBadge: Record<string, string> = {
pending_payment: 'badge--gray', pending_payment: 'badge--muted',
paid: 'badge--blue', paid: 'badge--primary',
lookup_started: 'badge--blue', lookup_started: 'badge--primary',
sent: 'badge--green', sent: 'badge--success',
delivered: 'badge--green', delivered: 'badge--success',
failed: 'badge--red', failed: 'badge--danger',
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
@ -44,47 +45,63 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="orders"> <div class="page">
<h1 class="orders__title">Mina beställningar</h1> <h1 class="page__title">Mina beställningar</h1>
<p class="orders__subtitle">Här kan du se dina tidigare beställningar.</p> <p class="page__subtitle">Här kan du se dina tidigare beställningar.</p>
<p v-if="loading" class="orders__loading">Laddar beställningar...</p> <p
v-if="loading"
<p v-else-if="error" class="orders__error">{{ error }}</p> class="text-muted text-center orders__loading"
role="status"
<p v-else-if="orders.length === 0" class="orders__empty"> >
Du har inga beställningar ännu. Laddar beställningar...
</p> </p>
<div v-else-if="error" class="message message--error" role="alert">
{{ error }}
</div>
<div v-else-if="orders.length === 0" class="orders__empty">
<div class="orders__empty-card">
<p class="orders__empty-title">Inga beställningar ännu</p>
<p class="orders__empty-text">
Följ dina brev och se tidigare skickade hälsningar.
</p>
<RouterLink to="/" class="btn btn--primary orders__empty-cta">
Skicka första brevet
</RouterLink>
</div>
</div>
<div v-else class="orders__list"> <div v-else class="orders__list">
<div v-for="order in orders" :key="order.id" class="orders__card"> <div v-for="order in orders" :key="order.id" class="orders__card">
<div class="orders__card-header"> <div class="orders__card-top">
<span class="orders__plate">{{ order.plate }}</span> <span class="orders__plate">{{ order.plate }}</span>
<span <span
class="orders__badge" class="badge"
:class="statusClasses[order.status] || 'badge--gray'" :class="statusBadge[order.status] || 'badge--muted'"
> >
{{ statusLabels[order.status] || order.status }} {{ statusLabels[order.status] || order.status }}
</span> </span>
</div> </div>
<div class="orders__card-body"> <div class="orders__card-meta">
<div class="orders__detail"> <span class="orders__meta-label">Datum</span>
<span class="orders__label">Datum</span> <span class="orders__meta-value">{{
<span class="orders__value">{{ formatDate(order.createdAt) }}</span> formatDate(order.createdAt)
</div> }}</span>
<div v-if="order.trackingId" class="orders__detail"> <template v-if="order.trackingId">
<span class="orders__label">Spårning</span> <span class="orders__meta-label">Spårning</span>
<a <a
class="orders__tracking-link" class="orders__tracking"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`" :href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{{ order.trackingId }} {{ order.trackingId }}
</a> </a>
</div> </template>
</div> </div>
</div> </div>
</div> </div>
@ -92,140 +109,117 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
.orders { .page {
max-width: 48rem; max-width: 48rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.orders__title { .page__title {
margin: 0 0 0.25rem 0; margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.orders__subtitle { .page__subtitle {
margin: 0 0 1.5rem 0; margin: 0 0 var(--space-xl) 0;
color: #718096;
font-size: 0.875rem; font-size: 0.875rem;
} color: var(--color-muted);
.orders__loading,
.orders__error,
.orders__empty {
margin: 2rem 0;
padding: 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
text-align: center;
}
.orders__loading {
color: #718096;
}
.orders__error {
background: #fff5f5;
border: 1px solid #fed7d7;
color: #c53030;
}
.orders__empty {
background: #f7fafc;
border: 1px solid #e2e8f0;
color: #718096;
} }
.orders__list { .orders__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--space-md);
} }
.orders__card { .orders__card {
background: #fff; background: var(--color-surface);
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
border-radius: 0.75rem; border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-card);
} }
.orders__card-header { .orders__card-top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 1.25rem; padding: var(--space-md) var(--space-lg);
background: #f7fafc; background: var(--color-border-light);
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid var(--color-border);
} }
.orders__plate { .orders__plate {
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 600; font-weight: 600;
color: #1a202c; color: var(--color-ink);
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.orders__badge { .orders__card-meta {
display: inline-block; padding: var(--space-md) var(--space-lg);
padding: 0.25rem 0.75rem; display: grid;
border-radius: 9999px; grid-template-columns: auto 1fr;
font-size: 0.75rem; gap: var(--space-sm) var(--space-lg);
font-weight: 600; align-items: center;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.badge--gray { .orders__meta-label {
background: #edf2f7;
color: #718096;
}
.badge--blue {
background: #ebf8ff;
color: #2b6cb0;
}
.badge--green {
background: #f0fff4;
color: #276749;
}
.badge--red {
background: #fff5f5;
color: #c53030;
}
.orders__card-body {
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.orders__detail {
display: flex;
gap: 0.75rem;
}
.orders__label {
min-width: 5rem;
font-size: 0.8125rem; font-size: 0.8125rem;
color: #a0aec0; color: var(--color-soft);
font-weight: 500;
white-space: nowrap;
}
.orders__meta-value {
font-size: 0.875rem;
color: var(--color-ink);
}
.orders__tracking {
font-size: 0.875rem;
color: var(--color-primary);
text-decoration: none;
font-weight: 500; font-weight: 500;
} }
.orders__value { .orders__tracking:hover {
font-size: 0.875rem;
color: #4a5568;
}
.orders__tracking-link {
font-size: 0.875rem;
color: #4299e1;
text-decoration: none;
}
.orders__tracking-link:hover {
text-decoration: underline; text-decoration: underline;
} }
.orders__empty {
padding: var(--space-2xl) 0;
text-align: center;
}
.orders__empty-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
box-shadow: var(--shadow-card);
}
.orders__empty-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-ink);
margin: 0 0 var(--space-sm) 0;
}
.orders__empty-text {
font-size: 0.875rem;
color: var(--color-muted);
margin: 0;
}
.orders__empty-cta {
margin-top: var(--space-md);
display: inline-flex;
}
.orders__loading {
padding: var(--space-2xl) 0;
}
</style> </style>

View file

@ -26,113 +26,101 @@ async function handlePay() {
</script> </script>
<template> <template>
<div class="payment"> <div class="page">
<h1 class="payment__title">Betalning</h1> <div class="page__card">
<p class="payment__subtitle"> <h1 class="page__title">Betalning</h1>
<p class="page__plate">
Registreringsnummer: <strong>{{ route.query.plate || '—' }}</strong> Registreringsnummer: <strong>{{ route.query.plate || '—' }}</strong>
</p> </p>
<div class="payment__card"> <div class="payment__summary">
<div class="payment__amount-row"> <div class="payment__row">
<span class="payment__label">Att betala</span> <span class="payment__label">Att betala</span>
<span class="payment__amount">49 kr</span> <span class="payment__amount">49 kr</span>
</div> </div>
</div>
<p v-if="error" class="payment__error">{{ error }}</p> <div
v-if="error"
class="message message--error"
style="margin-bottom: var(--space-md)"
>
{{ error }}
</div>
<button class="payment__button" :disabled="paying" @click="handlePay"> <button
{{ paying ? 'Bearbetar...' : 'Betalt' }} class="btn btn--primary btn--lg payment__submit"
:disabled="paying"
@click="handlePay"
>
{{ paying ? 'Bearbetar...' : 'Genomför testbetalning' }}
</button> </button>
<p class="payment__note"> <p class="payment__note">
Detta är en mock-betalning. I framtiden skickas du till Stripe. Detta är en testbetalning i utvecklingsmiljön.
</p> </p>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.payment { .page {
max-width: 28rem; max-width: 28rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.payment__title { .page__card {
margin: 0 0 0.25rem 0; background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.payment__subtitle { .page__plate {
margin: 0 0 1.5rem 0; margin: 0 0 var(--space-xl) 0;
color: #718096;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.payment__card { .payment__summary {
background: #fff; margin-bottom: var(--space-lg);
border: 1px solid #e2e8f0; padding-bottom: var(--space-lg);
border-radius: 0.75rem; border-bottom: 1px solid var(--color-border);
padding: 1.5rem;
} }
.payment__amount-row { .payment__row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1.25rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid #e2e8f0;
} }
.payment__label { .payment__label {
font-size: 0.875rem; font-size: 0.875rem;
color: #718096; color: var(--color-muted);
} }
.payment__amount { .payment__amount {
font-size: 1.25rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
color: #1a202c; color: var(--color-ink);
} }
.payment__error { .payment__submit {
margin: 0 0 0.75rem 0;
padding: 0.5rem 0.75rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.375rem;
color: #c53030;
font-size: 0.8125rem;
}
.payment__button {
width: 100%; width: 100%;
padding: 0.75rem;
border: none;
border-radius: 0.5rem;
background: #48bb78;
color: #fff;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.payment__button:hover:not(:disabled) {
background: #38a169;
}
.payment__button:disabled {
opacity: 0.6;
cursor: not-allowed;
} }
.payment__note { .payment__note {
margin: 0.75rem 0 0 0; margin: var(--space-md) 0 0 0;
color: #a0aec0;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-soft);
text-align: center; text-align: center;
} }
</style> </style>

View file

@ -70,45 +70,54 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<div class="register"> <div class="page">
<h1 class="register__title">Skapa konto</h1> <div class="page__card">
<p class="register__subtitle"> <h1 class="page__title">Skapa konto</h1>
<p class="page__subtitle">
Ange din e-postadress och ett lösenord för att skapa ett konto. Ange din e-postadress och ett lösenord för att skapa ett konto.
</p> </p>
<form class="register__form" @submit.prevent="handleSubmit"> <form class="page__form" @submit.prevent="handleSubmit">
<div class="register__field"> <div class="field">
<label for="email" class="register__label">E-postadress</label> <label for="email" class="field__label">E-postadress</label>
<input <input
id="email" id="email"
v-model="email" v-model="email"
type="email" type="email"
autocomplete="email" autocomplete="email"
class="register__input" class="field__input"
:class="{ 'register__input--error': emailError }" :class="{ 'field__input--error': emailError }"
:aria-invalid="!!emailError"
aria-describedby="email-error"
placeholder="namn@exempel.se" placeholder="namn@exempel.se"
@input="touched = true" @input="touched = true"
/> />
<p v-if="emailError" class="register__error">{{ emailError }}</p> <p v-if="emailError" id="email-error" class="field__error">
{{ emailError }}
</p>
</div> </div>
<div class="register__field"> <div class="field">
<label for="password" class="register__label">Lösenord</label> <label for="password" class="field__label">Lösenord</label>
<input <input
id="password" id="password"
v-model="password" v-model="password"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
class="register__input" class="field__input"
:class="{ 'register__input--error': passwordError }" :class="{ 'field__input--error': passwordError }"
:aria-invalid="!!passwordError"
aria-describedby="password-error"
placeholder="Minst 8 tecken" placeholder="Minst 8 tecken"
@input="touched = true" @input="touched = true"
/> />
<p v-if="passwordError" class="register__error">{{ passwordError }}</p> <p v-if="passwordError" id="password-error" class="field__error">
{{ passwordError }}
</p>
</div> </div>
<div class="register__field"> <div class="field">
<label for="confirm-password" class="register__label" <label for="confirm-password" class="field__label"
>Bekräfta lösenord</label >Bekräfta lösenord</label
> >
<input <input
@ -116,147 +125,84 @@ async function handleSubmit() {
v-model="confirmPassword" v-model="confirmPassword"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
class="register__input" class="field__input"
:class="{ 'register__input--error': confirmPasswordError }" :class="{ 'field__input--error': confirmPasswordError }"
:aria-invalid="!!confirmPasswordError"
aria-describedby="confirm-password-error"
placeholder="Upprepa lösenord" placeholder="Upprepa lösenord"
@input="touched = true" @input="touched = true"
/> />
<p v-if="confirmPasswordError" class="register__error"> <p
v-if="confirmPasswordError"
id="confirm-password-error"
class="field__error"
>
{{ confirmPasswordError }} {{ confirmPasswordError }}
</p> </p>
</div> </div>
<p v-if="errorMessage" class="register__api-error">{{ errorMessage }}</p> <div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
</div>
<button <button
type="submit" type="submit"
class="register__submit" class="btn btn--primary btn--lg register__submit"
:disabled="!isValid || submitting" :disabled="!isValid || submitting"
> >
{{ submitting ? 'Skapar konto...' : 'Skapa konto' }} {{ submitting ? 'Skapar konto...' : 'Skapa konto' }}
</button> </button>
</form> </form>
<p class="register__login-link"> <p class="page__footer-link">
Har du redan ett konto? Har du redan ett konto?
<RouterLink to="/logga-in">Logga in</RouterLink> <RouterLink to="/logga-in">Logga in</RouterLink>
</p> </p>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.register { .page {
max-width: 28rem; max-width: 28rem;
margin: 3rem auto 0; margin: var(--space-3xl) auto 0;
padding: 0 1rem; padding: 0 var(--space-lg);
} }
.register__title { .page__card {
margin: 0 0 0.25rem 0; background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: var(--color-ink);
} }
.register__subtitle { .page__subtitle {
margin: 0 0 1.5rem 0; margin: 0 0 var(--space-xl) 0;
color: #718096;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.register__form { .page__form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--space-md);
} }
.register__field { .page__footer-link {
display: flex; margin-top: var(--space-lg);
flex-direction: column; text-align: center;
gap: 0.375rem;
}
.register__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.register__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.register__input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.register__input--error {
border-color: #e53e3e;
}
.register__input--error:focus {
border-color: #e53e3e;
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.25);
}
.register__error {
margin: 0;
font-size: 0.8125rem;
color: #e53e3e;
}
.register__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted);
} }
.register__submit { .register__submit {
width: 100%; 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;
}
.register__submit:hover:not(:disabled) {
background: #2f855a;
}
.register__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.register__login-link {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: #718096;
}
.register__login-link a {
color: #4299e1;
text-decoration: none;
}
.register__login-link a:hover {
text-decoration: underline;
} }
</style> </style>