Merge pull request 'feature/cancel-edit-pending-orders' (#3) from feature/cancel-edit-pending-orders into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m3s
CI / E2E browser tests (push) Successful in 54s

Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/3
This commit is contained in:
jocke 2026-05-22 10:48:33 +00:00
commit c0902d0494
12 changed files with 1577 additions and 188 deletions

View file

@ -1,10 +1,44 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import AboutPage from '@/pages/AboutPage.vue'
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/om-oss', name: 'about', component: AboutPage },
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
],
})
}
describe('AboutPage', () => {
it('renders heading', () => {
const wrapper = mount(AboutPage)
it('renders heading and lead', () => {
const router = createTestRouter()
const wrapper = mount(AboutPage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Om Bilhej')
expect(wrapper.text()).toContain('Bilhej gör det enkelt')
})
it('renders how-it-works steps', () => {
const router = createTestRouter()
const wrapper = mount(AboutPage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Skriv brevet här')
expect(wrapper.text()).toContain('Vi postar åt dig')
})
it('links to home page', () => {
const router = createTestRouter()
const wrapper = mount(AboutPage, {
global: { plugins: [router] },
})
const cta = wrapper.find('a.about__cta-btn')
expect(cta.exists()).toBe(true)
expect(cta.attributes('href')).toBe('/')
})
})

View file

@ -8,10 +8,14 @@ function createTestRouter() {
history: createMemoryHistory(),
routes: [
{
path: '/om',
path: '/om-oss',
name: 'about',
component: { template: '<div>About</div>' },
},
{
path: '/om',
redirect: '/om-oss',
},
{
path: '/kontakt',
name: 'contact',
@ -40,7 +44,7 @@ describe('AppFooter', () => {
const links = wrapper.findAll('a')
expect(links[0].text()).toBe('Om oss')
expect(links[0].attributes('href')).toBe('/om')
expect(links[0].attributes('href')).toBe('/om-oss')
expect(links[1].text()).toBe('Kontakt')
expect(links[1].attributes('href')).toBe('/kontakt')

View file

@ -3,8 +3,23 @@ import { mount } from '@vue/test-utils'
import ContactPage from '@/pages/ContactPage.vue'
describe('ContactPage', () => {
it('renders heading', () => {
it('renders heading and lead', () => {
const wrapper = mount(ContactPage)
expect(wrapper.text()).toContain('Kontakta oss')
expect(wrapper.text()).toContain('klagomål')
})
it('renders general support email', () => {
const wrapper = mount(ContactPage)
const link = wrapper.find('a[href="mailto:kontakt@bilhej.se"]')
expect(link.exists()).toBe(true)
expect(link.text()).toBe('kontakt@bilhej.se')
})
it('renders complaints email', () => {
const wrapper = mount(ContactPage)
const link = wrapper.find('a[href="mailto:jcamorling@gmail.com"]')
expect(link.exists()).toBe(true)
expect(wrapper.text()).toContain('jcamorling@gmail.com')
})
})

View file

@ -337,4 +337,42 @@ describe('OrdersPage', () => {
const badge = wrapper.find('.badge')
expect(badge.classes()).toContain('badge--primary')
})
it('shows expand toggle for long messages and reveals full text', async () => {
const longText =
'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å när man kör bil i rusningstid och tempot blir högt.'
const ordersWithLongMessage = [
{
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
plate: 'ABC123',
letterText: longText,
status: 'processing',
trackingId: null,
createdAt: '2026-05-11T12:00:00Z',
},
]
mockOrdersFetch(ordersWithLongMessage)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const card = wrapper.find('.orders__card')
const preview = card.find('.orders__preview')
const toggle = card.find('.orders__preview-toggle')
expect(toggle.exists()).toBe(true)
expect(toggle.text()).toBe('Visa mer')
expect(preview.classes()).not.toContain('orders__preview--expanded')
await toggle.trigger('click')
expect(preview.classes()).toContain('orders__preview--expanded')
expect(toggle.text()).toBe('Visa mindre')
expect(card.text()).toContain(longText)
})
it('does not show expand toggle for short messages', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
})
})

View file

@ -6,11 +6,10 @@ import { RouterLink } from 'vue-router'
<footer class="app-footer">
<div class="app-footer__inner">
<p class="app-footer__tagline">
Bilhej hjälper dig att skicka brev till bilägare via
registreringsnummer.
Skriv brevet här. Vi postar det till rätt bilägare.
</p>
<nav class="app-footer__links">
<RouterLink to="/om" class="app-footer__link">Om oss</RouterLink>
<RouterLink to="/om-oss" class="app-footer__link">Om oss</RouterLink>
<RouterLink to="/kontakt" class="app-footer__link">Kontakt</RouterLink>
<RouterLink to="/integritetspolicy" class="app-footer__link"
>Integritetspolicy</RouterLink

View file

@ -1,39 +1,225 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
const highlights = [
{
title: 'Skriv brevet här',
description:
'Välj mall eller skriv fritt. Du ser hela brevet innan du betalar 49 kr via Swish.',
},
{
title: 'Registreringsnummer räcker',
description:
'Du behöver inte veta vem som äger bilen eller var personen bor. Vi kopplar brevet till rätt mottagare.',
},
{
title: 'Vi postar åt dig',
description:
'Efter betalning hanterar vi utskick manuellt. Du följer status i Mina beställningar och får spårning när brevet skickats.',
},
]
</script>
<template>
<div class="page">
<div class="page__card">
<h1>Om Bilhej</h1>
<div class="about">
<section class="about__hero">
<p class="about__eyebrow">Om oss</p>
<h1 class="about__title">Om Bilhej</h1>
<p class="about__lead">
Bilhej gör det enkelt att en bilägare med ett fysiskt brev. Du
skriver meddelandet, vi sköter utskick och post.
</p>
</section>
<section class="about__section">
<h2 class="about__section-title">Vad vi gör</h2>
<div class="about__prose">
<p>
Bilhej är en tjänst som låter dig skicka fysiska brev till fordonsägare
via registreringsnummer.
Många situationer i trafiken eller parkeringen är enklare att lösa
med ett kort, respektfullt brev än med lapp i vindrutan eller
konfrontation plats. Bilhej är till för det.
</p>
<p>
Du anger registreringsnummer, skriver vad du vill säga och betalar.
Därefter ser vi till att brevet når rätt person med vanlig post. Du
behöver inte hantera adress eller jaga upp ägaren själv.
</p>
<p>
Tjänsten passar till exempel köpförfrågan, tips om något bilen,
vänlig feedback efter trafik eller ett meddelande du formulerar helt
själv.
</p>
</div>
</section>
<section class="about__section">
<h2 class="about__section-title"> fungerar det</h2>
<div class="about__cards">
<article
v-for="(item, index) in highlights"
:key="item.title"
class="about__card"
>
<span class="about__card-step">Steg {{ index + 1 }}</span>
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</article>
</div>
</section>
<section class="about__section about__section--cta">
<div class="about__cta-box">
<h2 class="about__cta-title">Redo att skriva?</h2>
<p class="about__cta-text">
Börja med registreringsnumret startsidan. Det tar bara några
minuter att skriva och betala.
</p>
<RouterLink to="/" class="btn btn--primary about__cta-btn">
Till startsidan
</RouterLink>
</div>
</section>
</div>
</template>
<style scoped>
.page {
max-width: 36rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
.about {
max-width: 48rem;
margin: 0 auto;
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
}
.page__card {
.about__hero {
margin-bottom: var(--space-2xl);
padding: var(--space-2xl);
background: linear-gradient(
145deg,
var(--color-surface) 0%,
#f8faff 55%,
var(--color-paper) 100%
);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.about__eyebrow {
display: inline-block;
margin: 0 0 var(--space-md) 0;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-primary-dark);
background: var(--color-primary-soft);
border: 1px solid #bfdbfe;
padding: 0.35rem 0.75rem;
border-radius: var(--radius-full);
}
.about__title {
margin: 0 0 var(--space-md) 0;
font-size: clamp(1.75rem, 4vw, 2.25rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--color-ink);
}
.about__lead {
margin: 0;
font-size: 1.0625rem;
line-height: 1.75;
color: var(--color-muted);
}
.about__section {
margin-bottom: var(--space-2xl);
}
.about__section-title {
margin: 0 0 var(--space-lg) 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-ink);
}
.about__prose p {
margin: 0 0 var(--space-md) 0;
font-size: 0.9375rem;
line-height: 1.75;
color: var(--color-muted);
}
.about__prose p:last-child {
margin-bottom: 0;
}
.about__cards {
display: grid;
gap: var(--space-md);
}
.about__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
padding: var(--space-lg);
box-shadow: var(--shadow-card);
}
h1 {
margin: 0 0 var(--space-md) 0;
font-size: 1.5rem;
.about__card-step {
display: inline-flex;
margin-bottom: var(--space-sm);
padding: 0.2rem 0.65rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-primary-dark);
background: var(--color-primary-soft);
border: 1px solid #bfdbfe;
border-radius: var(--radius-full);
}
p {
.about__card h3 {
margin: 0 0 var(--space-sm) 0;
font-size: 1rem;
color: var(--color-ink);
}
.about__card p {
margin: 0;
line-height: 1.7;
font-size: 0.9375rem;
line-height: 1.65;
color: var(--color-muted);
}
.about__cta-box {
padding: var(--space-xl);
text-align: center;
background: linear-gradient(
135deg,
var(--color-primary-soft) 0%,
#eef2ff 100%
);
border: 1px solid #bfdbfe;
border-radius: var(--radius-xl);
}
.about__cta-title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.25rem;
color: var(--color-primary-dark);
}
.about__cta-text {
margin: 0 0 var(--space-lg) 0;
font-size: 0.9375rem;
line-height: 1.65;
color: var(--color-primary-dark);
}
.about__cta-btn {
display: inline-flex;
}
</style>

View file

@ -22,7 +22,7 @@ const canSubmit = computed(
)
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: 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: kontakt@bilhej.se'
function handleTemplateSelect(template: LetterTemplate) {
letterText.value = template.body

View file

@ -1,25 +1,143 @@
<script setup lang="ts"></script>
<script setup lang="ts">
const contactChannels = [
{
variant: 'general',
title: 'Frågor om tjänsten',
description:
'Beställningar, betalning, tekniska problem eller allmänna frågor om hur Bilhej fungerar.',
email: 'kontakt@bilhej.se',
label: 'Skicka e-post',
},
{
variant: 'complaints',
title: 'Klagomål och synpunkter',
description:
'Om något gått fel eller du vill lämna ett klagomål direkt till oss som driver tjänsten.',
email: 'jcamorling@gmail.com',
label: 'Skicka klagomål',
},
]
</script>
<template>
<div class="page">
<div class="page__card">
<h1>Kontakta oss</h1>
<p>
Har du frågor eller feedback? Hör av dig till oss
<a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>.
<div class="contact">
<section class="contact__hero">
<p class="contact__eyebrow">Kontakt</p>
<h1 class="contact__title">Kontakta oss</h1>
<p class="contact__lead">
Vi svarar snart vi kan. Välj rätt adress beroende om det gäller en
vanlig fråga eller ett klagomål.
</p>
</section>
<section class="contact__section">
<div class="contact__cards">
<article
v-for="channel in contactChannels"
:key="channel.email"
class="contact__card"
:class="`contact__card--${channel.variant}`"
>
<h2>{{ channel.title }}</h2>
<p>{{ channel.description }}</p>
<a class="contact__email" :href="`mailto:${channel.email}`">
{{ channel.email }}
</a>
<a
class="btn btn--ghost contact__btn"
:href="`mailto:${channel.email}`"
>
{{ channel.label }}
</a>
</article>
</div>
</section>
<section class="contact__section">
<div class="contact__tips">
<h2 class="contact__tips-title">Innan du skriver</h2>
<ul class="contact__tips-list">
<li>
Har du en pågående beställning? Kontrollera
<strong>Mina beställningar</strong> först. Där ser du status och
spårning.
</li>
<li>
Vid klagomål, skriv gärna med beställnings-ID och
registreringsnummer kan vi hitta ärendet snabbare.
</li>
<li>
Vi läser all e-post manuellt och återkommer när vi har tittat
ditt ärende.
</li>
</ul>
</div>
</section>
</div>
</template>
<style scoped>
.page {
max-width: 36rem;
margin: var(--space-3xl) auto 0;
padding: 0 var(--space-lg);
.contact {
max-width: 48rem;
margin: 0 auto;
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
}
.page__card {
.contact__hero {
margin-bottom: var(--space-2xl);
padding: var(--space-2xl);
background: linear-gradient(
145deg,
var(--color-surface) 0%,
#f8faff 55%,
var(--color-paper) 100%
);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.contact__eyebrow {
display: inline-block;
margin: 0 0 var(--space-md) 0;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-primary-dark);
background: var(--color-primary-soft);
border: 1px solid #bfdbfe;
padding: 0.35rem 0.75rem;
border-radius: var(--radius-full);
}
.contact__title {
margin: 0 0 var(--space-md) 0;
font-size: clamp(1.75rem, 4vw, 2.25rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--color-ink);
}
.contact__lead {
margin: 0;
font-size: 1.0625rem;
line-height: 1.75;
color: var(--color-muted);
}
.contact__section {
margin-bottom: var(--space-2xl);
}
.contact__cards {
display: grid;
gap: var(--space-lg);
}
.contact__card {
position: relative;
overflow: hidden;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
@ -27,13 +145,80 @@
box-shadow: var(--shadow-card);
}
h1 {
margin: 0 0 var(--space-md) 0;
font-size: 1.5rem;
.contact__card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--contact-accent);
}
p {
.contact__card--general {
--contact-accent: linear-gradient(90deg, #1d4ed8, #60a5fa);
}
.contact__card--complaints {
--contact-accent: linear-gradient(90deg, #4338ca, #818cf8);
}
.contact__card h2 {
margin: 0 0 var(--space-sm) 0;
font-size: 1.125rem;
color: var(--color-ink);
}
.contact__card p {
margin: 0 0 var(--space-md) 0;
font-size: 0.9375rem;
line-height: 1.65;
color: var(--color-muted);
}
.contact__email {
display: inline-block;
margin-bottom: var(--space-md);
font-size: 1rem;
font-weight: 600;
color: var(--color-primary);
word-break: break-all;
}
.contact__email:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.contact__btn {
display: inline-flex;
}
.contact__tips {
padding: var(--space-xl);
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
}
.contact__tips-title {
margin: 0 0 var(--space-md) 0;
font-size: 1.0625rem;
color: var(--color-ink);
}
.contact__tips-list {
margin: 0;
line-height: 1.7;
padding-left: 1.25rem;
color: var(--color-muted);
}
.contact__tips-list li + li {
margin-top: var(--space-sm);
}
.contact__tips-list li {
font-size: 0.9375rem;
line-height: 1.65;
}
</style>

View file

@ -28,7 +28,7 @@ const canSubmit = computed(
)
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: 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: kontakt@bilhej.se'
function handleTemplateSelect(template: LetterTemplate) {
letterText.value = template.body

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,27 @@ const loading = ref(true)
const error = ref('')
const actionError = ref('')
const cancellingId = ref<string | null>(null)
const expandedPreviewIds = ref<Set<string>>(new Set())
const PREVIEW_CHAR_LIMIT = 120
function isLongMessage(text: string): boolean {
return text.length > PREVIEW_CHAR_LIMIT
}
function isPreviewExpanded(orderId: string): boolean {
return expandedPreviewIds.value.has(orderId)
}
function togglePreview(orderId: string) {
const next = new Set(expandedPreviewIds.value)
if (next.has(orderId)) {
next.delete(orderId)
} else {
next.add(orderId)
}
expandedPreviewIds.value = next
}
const pendingOrders = computed(() =>
orders.value.filter((order) => order.status === 'pending_payment'),
@ -150,7 +171,22 @@ onMounted(loadOrders)
</div>
<div class="orders__preview-box">
<p class="orders__preview">{{ order.letterText }}</p>
<p
class="orders__preview"
:class="{
'orders__preview--expanded': isPreviewExpanded(order.id),
}"
>
{{ order.letterText }}
</p>
<button
v-if="isLongMessage(order.letterText)"
type="button"
class="orders__preview-toggle"
@click="togglePreview(order.id)"
>
{{ isPreviewExpanded(order.id) ? 'Visa mindre' : 'Visa mer' }}
</button>
</div>
<p class="orders__order-date">
@ -233,7 +269,22 @@ onMounted(loadOrders)
</div>
<div class="orders__preview-box">
<p class="orders__preview">{{ order.letterText }}</p>
<p
class="orders__preview"
:class="{
'orders__preview--expanded': isPreviewExpanded(order.id),
}"
>
{{ order.letterText }}
</p>
<button
v-if="isLongMessage(order.letterText)"
type="button"
class="orders__preview-toggle"
@click="togglePreview(order.id)"
>
{{ isPreviewExpanded(order.id) ? 'Visa mindre' : 'Visa mer' }}
</button>
</div>
<p class="orders__order-date">
@ -374,6 +425,29 @@ onMounted(loadOrders)
overflow: hidden;
}
.orders__preview--expanded {
display: block;
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
}
.orders__preview-toggle {
margin-top: var(--space-sm);
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-primary);
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.orders__preview-toggle:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.orders__order-date {
margin: 0 0 var(--space-sm) 0;
font-size: 0.8125rem;

View file

@ -84,10 +84,14 @@ const router = createRouter({
meta: { guestOnly: true },
},
{
path: '/om',
path: '/om-oss',
name: 'about',
component: AboutPage,
},
{
path: '/om',
redirect: '/om-oss',
},
{
path: '/kontakt',
name: 'contact',