Compare commits

...

6 commits

Author SHA1 Message Date
c0902d0494 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
2026-05-22 10:48:33 +00:00
081a1f90d3 Add expand/collapse for long letter previews on orders page.
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 1m59s
CI / E2E browser tests (pull_request) Successful in 57s
- Truncate previews over 120 characters with a Visa mer toggle
- Allow per-order expand state on pending and completed cards
- Add styles for expanded preview and toggle button
- Cover long and short message behavior in OrdersPage tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:46 +02:00
162002dfb1 Fix printed letter footer contact address to kontakt@bilhej.se.
- Replace obsolete hej@bilhalsning.se in ComposePage GDPR footer
- Apply the same correction on EditOrderPage for edited orders

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:46 +02:00
60cb07fc89 Redesign contact page with separate support and complaint channels.
- Replace single placeholder mailto with two contact cards
- Show kontakt@bilhej.se for general questions and service issues
- Show jcamorling@gmail.com for complaints with clearer labeling
- Add tips section pointing users to Mina beställningar first
- Extend ContactPage tests for both email addresses

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:46 +02:00
758ace1b92 Redesign about page and move route to /om-oss.
- Replace placeholder about card with hero, prose, steps, and CTA
- Add primary route /om-oss with redirect from legacy /om
- Update footer tagline and Om oss link to match new URL
- Extend AboutPage and AppFooter tests for new content and routing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:44 +02:00
139250b2ac Redesign homepage with clearer marketing sections and honest copy.
- Add structured use-case cards mapped to real letter templates
- Replace generic hero with bullets on traceability, anonymity, and timing
- Add three-step how-it-works flow with manual posting disclaimer
- Reframe trust section around convenience rather than hidden addresses
- Refresh layout with gradient heroes, icons, and consistent section styling

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:47:42 +02:00
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>
<p>
Bilhej är en tjänst som låter dig skicka fysiska brev till fordonsägare
via registreringsnummer.
<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>
</div>
</section>
<section class="about__section">
<h2 class="about__section-title">Vad vi gör</h2>
<div class="about__prose">
<p>
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>
</div>
</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',