Compare commits
3 commits
210ac87ede
...
c7d443f236
| Author | SHA1 | Date | |
|---|---|---|---|
| c7d443f236 | |||
| d70196112d | |||
| 4c6094446b |
25 changed files with 548 additions and 25 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ target/
|
|||
*.jar
|
||||
*.war
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
|
@ -39,6 +40,10 @@ Thumbs.db
|
|||
docker-compose.override.yml
|
||||
certs/
|
||||
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Java
|
||||
*.hprof
|
||||
|
||||
|
|
|
|||
28
AGENTS.md
28
AGENTS.md
|
|
@ -24,11 +24,23 @@ PostgreSQL 16. Deployed via Docker Compose.
|
|||
|
||||
Always run these after making changes to verify nothing is broken.
|
||||
|
||||
Gradle lives at repo root. All commands below run from the repo root unless noted.
|
||||
|
||||
### Quick start (everything)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # first time only, then fill in keys
|
||||
docker compose up -d # starts postgres, backend, frontend
|
||||
./gradlew up # same as above (Gradle wrapper)
|
||||
```
|
||||
|
||||
### All-in-one
|
||||
|
||||
```bash
|
||||
./gradlew check # frontend lint → frontend test → backend test → integration test
|
||||
./gradlew up # docker compose up -d
|
||||
./gradlew down # docker compose down
|
||||
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
|
||||
```
|
||||
|
||||
### Frontend (Vue.js 3 + Vite)
|
||||
|
|
@ -45,10 +57,8 @@ npm run test # vitest
|
|||
### Backend (Spring Boot 4 + Java 21)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./gradlew bootRun # dev server on :8080
|
||||
./gradlew test # JUnit 5 + Mockito
|
||||
./gradlew check # full verification including integration tests
|
||||
./gradlew :backend:bootRun # dev server on :8080
|
||||
./gradlew :backend:test # JUnit 5 + Mockito (backend only)
|
||||
```
|
||||
|
||||
### Stripe webhooks (local testing)
|
||||
|
|
@ -80,7 +90,8 @@ bilhej/
|
|||
│ │ ├── router/ # Vue Router config
|
||||
│ │ └── assets/ # Static files, CSS
|
||||
│ └── ...
|
||||
├── backend/ # Spring Boot 4 (Java 21)
|
||||
├── backend/ # Spring Boot 4 (Java 21) — Gradle subproject
|
||||
│ ├── build.gradle # Spring Boot plugin, Java deps, test config
|
||||
│ ├── src/main/java/se/bilhalsning/
|
||||
│ │ ├── config/ # @Configuration classes
|
||||
│ │ ├── controller/ # REST controllers
|
||||
|
|
@ -96,7 +107,7 @@ bilhej/
|
|||
│ ├── application-docker.yml # docker profile (PostgreSQL)
|
||||
│ └── db/migration/ # Flyway migrations
|
||||
├── docker/ # Dockerfiles
|
||||
│ ├── backend.Dockerfile # dev: JDK 21 + gradle bootRun
|
||||
│ ├── backend.Dockerfile # dev: JDK 21 + gradle :backend:bootRun
|
||||
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle build → JRE Alpine, non-root)
|
||||
│ ├── frontend.Dockerfile # dev: Node 24 + vite dev server
|
||||
│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node build → nginx)
|
||||
|
|
@ -104,6 +115,11 @@ bilhej/
|
|||
│ └── entrypoint.sh # prod: self-signed cert generation
|
||||
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR)
|
||||
├── docker-compose.prod.yml # prod: multi-stage images, no source mounts, restart always
|
||||
├── gradlew # Gradle wrapper (repo root)
|
||||
├── gradle/
|
||||
│ └── wrapper/
|
||||
├── settings.gradle # rootProject.name + include 'backend'
|
||||
├── build.gradle # convenience tasks: check, up, down, reset
|
||||
├── .env.example
|
||||
├── AGENTS.md # This file
|
||||
├── README.md
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -34,7 +34,7 @@ The user enters a registration number, composes a letter (from a template or fre
|
|||
git clone <repo-url> bilhej
|
||||
cd bilhej
|
||||
cp .env.example .env # fill in your keys
|
||||
docker compose up -d
|
||||
docker compose up -d # or: ./gradlew up
|
||||
```
|
||||
|
||||
The app will be available at:
|
||||
|
|
@ -72,7 +72,7 @@ No CORS configuration needed in development — the browser never calls the back
|
|||
**Spring profiles:**
|
||||
| Profile | Datasource | Use |
|
||||
|---------|-----------|-----|
|
||||
| default | H2 in-memory | Local IDE dev (`./gradlew bootRun`) |
|
||||
| default | H2 in-memory | Local IDE dev (`./gradlew :backend:bootRun`) |
|
||||
| `docker` | PostgreSQL via `docker-compose.yml` | Docker Compose dev |
|
||||
| `prod` | PostgreSQL (production config) | Deploy (`docker-compose.prod.yml`) |
|
||||
|
||||
|
|
@ -130,17 +130,21 @@ bilhej/
|
|||
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR)
|
||||
├── docker-compose.prod.yml # prod: multi-stage builds, no source mounts, restart: unless-stopped
|
||||
├── docker/
|
||||
│ ├── backend.Dockerfile # dev: JDK + gradle bootRun
|
||||
│ ├── backend.Dockerfile # dev: JDK + gradle :backend:bootRun
|
||||
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle → JRE Alpine, non-root)
|
||||
│ ├── frontend.Dockerfile # dev: Node + vite dev server
|
||||
│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node → nginx)
|
||||
│ ├── nginx.conf # prod: SPA fallback + /api proxy
|
||||
│ └── entrypoint.sh # prod: self-signed cert generation
|
||||
├── gradlew # Gradle wrapper (run from repo root)
|
||||
├── gradle/
|
||||
│ └── wrapper/
|
||||
├── settings.gradle # rootProject.name + include 'backend'
|
||||
├── build.gradle # convenience tasks: check, up, down, reset
|
||||
├── .env.example
|
||||
├── README.md
|
||||
├── REQUIREMENTS.md
|
||||
├── CODING_GUIDELINES.md
|
||||
└── ARCHITECTURE.md
|
||||
└── CODING_GUIDELINES.md
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -149,7 +153,7 @@ bilhej/
|
|||
|
||||
| Aspect | `docker compose up -d` | `docker compose -f docker-compose.prod.yml up -d` |
|
||||
|--------|------------------------|---------------------------------------------------|
|
||||
| Backend | `./gradlew bootRun` (compiles on change) | Multi-stage build → `java -jar app.jar` |
|
||||
| Backend | `./gradlew :backend:bootRun` (compiles on change) | Multi-stage build → `java -jar app.jar` |
|
||||
| Backend image | `eclipse-temurin:21-jdk` (~400 MB) | `eclipse-temurin:21-jre-alpine` (~200 MB) |
|
||||
| Backend user | root | `bilhej` (non-root) |
|
||||
| Frontend | Vite dev server (HMR, `--host 0.0.0.0`) | nginx serving static `dist/` |
|
||||
|
|
@ -163,19 +167,27 @@ bilhej/
|
|||
|
||||
## Development
|
||||
|
||||
### All-in-one (from repo root)
|
||||
|
||||
```bash
|
||||
./gradlew check # lint → frontend test → backend test → integration test
|
||||
./gradlew up # docker compose up -d
|
||||
./gradlew down # docker compose down
|
||||
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
|
||||
```
|
||||
|
||||
### Frontend (dev server with HMR)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm install # first time only
|
||||
npm run dev # :3000 with HMR
|
||||
```
|
||||
|
||||
### Backend (IDE or CLI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./gradlew bootRun
|
||||
./gradlew :backend:bootRun # :8080, profile: default (H2)
|
||||
```
|
||||
|
||||
### Stripe Webhooks (local testing)
|
||||
|
|
@ -184,10 +196,15 @@ cd backend
|
|||
stripe listen --forward-to localhost:8080/api/webhooks/stripe
|
||||
```
|
||||
|
||||
### Database reset
|
||||
|
||||
```bash
|
||||
./gradlew reset # wipes DB volume and restarts containers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [REQUIREMENTS.md](./REQUIREMENTS.md) — Full product requirements and business model
|
||||
- [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) — Code conventions and standards
|
||||
- [ARCHITECTURE.md](./ARCHITECTURE.md) — Detailed architecture and data flow
|
||||
|
|
|
|||
38
build.gradle
Normal file
38
build.gradle
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
plugins {
|
||||
id 'base'
|
||||
}
|
||||
|
||||
tasks.register('frontendLint', Exec) {
|
||||
description = 'Run ESLint in the frontend directory'
|
||||
workingDir = file("${rootProject.projectDir}/frontend")
|
||||
commandLine 'npm', 'run', 'lint'
|
||||
}
|
||||
|
||||
tasks.register('frontendTest', Exec) {
|
||||
description = 'Run Vitest in the frontend directory'
|
||||
dependsOn frontendLint
|
||||
workingDir = file("${rootProject.projectDir}/frontend")
|
||||
commandLine 'npm', 'run', 'test'
|
||||
}
|
||||
|
||||
tasks.named('check').configure {
|
||||
dependsOn frontendLint, frontendTest
|
||||
}
|
||||
|
||||
tasks.register('up', Exec) {
|
||||
description = 'Start all services via Docker Compose'
|
||||
workingDir = rootProject.projectDir
|
||||
commandLine 'docker', 'compose', 'up', '-d'
|
||||
}
|
||||
|
||||
tasks.register('down', Exec) {
|
||||
description = 'Stop all Docker Compose services'
|
||||
workingDir = rootProject.projectDir
|
||||
commandLine 'docker', 'compose', 'down'
|
||||
}
|
||||
|
||||
tasks.register('reset', Exec) {
|
||||
description = 'Wipe database and restart all services'
|
||||
workingDir = rootProject.projectDir
|
||||
commandLine 'bash', '-c', 'docker compose down -v && docker compose up -d'
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ services:
|
|||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- .:/app
|
||||
- gradle-cache:/root/.gradle
|
||||
|
||||
frontend:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
FROM eclipse-temurin:21-jdk
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["./gradlew", "bootRun", "--no-daemon"]
|
||||
ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<AppHeader />
|
||||
<main class="app__main">
|
||||
<RouterView />
|
||||
</main>
|
||||
<AppFooter />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.app__main {
|
||||
min-height: calc(100vh - 12rem);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
10
frontend/src/__tests__/AboutPage.spec.ts
Normal file
10
frontend/src/__tests__/AboutPage.spec.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AboutPage from '@/pages/AboutPage.vue'
|
||||
|
||||
describe('AboutPage', () => {
|
||||
it('renders heading', () => {
|
||||
const wrapper = mount(AboutPage)
|
||||
expect(wrapper.text()).toContain('Om BilHälsning')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,23 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import App from '@/App.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
import router from '@/router'
|
||||
|
||||
describe('App', () => {
|
||||
it('renders AppHeader and AppFooter', async () => {
|
||||
router.push('/')
|
||||
await router.isReady()
|
||||
const wrapper = mount(App, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
})
|
||||
expect(wrapper.findComponent(AppHeader).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(AppFooter).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders RouterView with HomePage content', async () => {
|
||||
router.push('/')
|
||||
await router.isReady()
|
||||
|
|
@ -12,6 +26,6 @@ describe('App', () => {
|
|||
plugins: [router],
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('BilHälsning')
|
||||
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
54
frontend/src/__tests__/AppFooter.spec.ts
Normal file
54
frontend/src/__tests__/AppFooter.spec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/om',
|
||||
name: 'about',
|
||||
component: { template: '<div>About</div>' },
|
||||
},
|
||||
{
|
||||
path: '/kontakt',
|
||||
name: 'contact',
|
||||
component: { template: '<div>Contact</div>' },
|
||||
},
|
||||
{
|
||||
path: '/integritetspolicy',
|
||||
name: 'privacy',
|
||||
component: { template: '<div>Privacy</div>' },
|
||||
},
|
||||
{
|
||||
path: '/villkor',
|
||||
name: 'terms',
|
||||
component: { template: '<div>Terms</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppFooter', () => {
|
||||
it('renders all four links', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppFooter, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
|
||||
expect(links[0].text()).toBe('Om oss')
|
||||
expect(links[0].attributes('href')).toBe('/om')
|
||||
|
||||
expect(links[1].text()).toBe('Kontakt')
|
||||
expect(links[1].attributes('href')).toBe('/kontakt')
|
||||
|
||||
expect(links[2].text()).toBe('Integritetspolicy')
|
||||
expect(links[2].attributes('href')).toBe('/integritetspolicy')
|
||||
|
||||
expect(links[3].text()).toBe('Villkor')
|
||||
expect(links[3].attributes('href')).toBe('/villkor')
|
||||
})
|
||||
})
|
||||
33
frontend/src/__tests__/AppHeader.spec.ts
Normal file
33
frontend/src/__tests__/AppHeader.spec.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppHeader', () => {
|
||||
it('renders the logo text', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('BilHälsning')
|
||||
})
|
||||
|
||||
it('has a link to home', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const homeLink = links.find((a) => a.attributes('href') === '/')
|
||||
expect(homeLink).toBeTruthy()
|
||||
})
|
||||
})
|
||||
43
frontend/src/__tests__/ComposePage.spec.ts
Normal file
43
frontend/src/__tests__/ComposePage.spec.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ComposePage from '@/pages/ComposePage.vue'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/compose', name: 'compose', component: ComposePage }],
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComposePage', () => {
|
||||
it('renders heading', async () => {
|
||||
const router = createTestRouter()
|
||||
router.push('/compose')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Skriv ditt brev')
|
||||
})
|
||||
|
||||
it('displays plate from query param', async () => {
|
||||
const router = createTestRouter()
|
||||
router.push({ path: '/compose', query: { plate: 'ABC123' } })
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('ABC123')
|
||||
})
|
||||
|
||||
it('does not show plate when no query param', async () => {
|
||||
const router = createTestRouter()
|
||||
router.push('/compose')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.find('.compose__plate').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
10
frontend/src/__tests__/ContactPage.spec.ts
Normal file
10
frontend/src/__tests__/ContactPage.spec.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ContactPage from '@/pages/ContactPage.vue'
|
||||
|
||||
describe('ContactPage', () => {
|
||||
it('renders heading', () => {
|
||||
const wrapper = mount(ContactPage)
|
||||
expect(wrapper.text()).toContain('Kontakta oss')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,85 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import HomePage from '@/pages/HomePage.vue'
|
||||
import ComposePage from '@/pages/ComposePage.vue'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomePage },
|
||||
{ path: '/compose', name: 'compose', component: ComposePage },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function mountHome(router: ReturnType<typeof createTestRouter>) {
|
||||
return mount(HomePage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('mounts successfully', () => {
|
||||
const wrapper = mount(HomePage)
|
||||
expect(wrapper.text()).toContain('BilHälsning')
|
||||
it('renders subtitle', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mountHome(router)
|
||||
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare')
|
||||
})
|
||||
|
||||
it('does not show CTA button initially', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mountHome(router)
|
||||
expect(wrapper.find('.home__cta').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show CTA while loading', async () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mountHome(router)
|
||||
const plateInput = wrapper.findComponent({ name: 'PlateInput' })
|
||||
|
||||
await plateInput.vm.$emit('lookup', 'ABC123')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.home__cta').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show CTA after not-found', async () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mountHome(router)
|
||||
const plateInput = wrapper.findComponent({ name: 'PlateInput' })
|
||||
|
||||
await plateInput.vm.$emit('lookup', 'UNKNOWN')
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
expect(wrapper.find('.home__cta').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows CTA button when vehicle data present', async () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mountHome(router)
|
||||
const plateInput = wrapper.findComponent({ name: 'PlateInput' })
|
||||
|
||||
await plateInput.vm.$emit('update:modelValue', 'ABC123')
|
||||
await plateInput.vm.$emit('lookup', 'ABC123')
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
const cta = wrapper.find('.home__cta')
|
||||
expect(cta.exists()).toBe(true)
|
||||
expect(cta.text()).toBe('Skicka ett brev till ägaren')
|
||||
})
|
||||
|
||||
it('CTA links to compose page with plate query param', async () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mountHome(router)
|
||||
const plateInput = wrapper.findComponent({ name: 'PlateInput' })
|
||||
|
||||
await plateInput.vm.$emit('update:modelValue', 'ABC123')
|
||||
await plateInput.vm.$emit('lookup', 'ABC123')
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
const cta = wrapper.find('.home__cta')
|
||||
const href = cta.attributes('href')
|
||||
expect(href).toBe('/compose?plate=ABC123')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
42
frontend/src/components/AppFooter.vue
Normal file
42
frontend/src/components/AppFooter.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<nav class="app-footer__links">
|
||||
<RouterLink to="/om" 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
|
||||
>
|
||||
<RouterLink to="/villkor" class="app-footer__link">Villkor</RouterLink>
|
||||
</nav>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
background: #f7fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-footer__links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-footer__link {
|
||||
color: #718096;
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.app-footer__link:hover {
|
||||
color: #1a202c;
|
||||
}
|
||||
</style>
|
||||
45
frontend/src/components/AppHeader.vue
Normal file
45
frontend/src/components/AppHeader.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
|
||||
<nav class="app-header__nav">
|
||||
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.app-header__logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-header__link {
|
||||
color: #4a5568;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.app-header__link:hover {
|
||||
color: #1a202c;
|
||||
}
|
||||
</style>
|
||||
19
frontend/src/pages/AboutPage.vue
Normal file
19
frontend/src/pages/AboutPage.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>Om BilHälsning</h1>
|
||||
<p>
|
||||
BilHälsning är en tjänst som låter dig skicka fysiska brev till
|
||||
fordonsägare via registreringsnummer.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.about {
|
||||
max-width: 28rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
</style>
|
||||
26
frontend/src/pages/ComposePage.vue
Normal file
26
frontend/src/pages/ComposePage.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const plate = (route.query.plate as string) || ''
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compose">
|
||||
<h1>Skriv ditt brev</h1>
|
||||
<p v-if="plate" class="compose__plate">Registreringsnummer: {{ plate }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compose {
|
||||
max-width: 28rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.compose__plate {
|
||||
color: #4a5568;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
19
frontend/src/pages/ContactPage.vue
Normal file
19
frontend/src/pages/ContactPage.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="contact">
|
||||
<h1>Kontakta oss</h1>
|
||||
<p>
|
||||
Har du frågor eller feedback? Hör av dig till oss på
|
||||
<a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact {
|
||||
max-width: 28rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import PlateInput from '@/components/PlateInput.vue'
|
||||
import VehicleInfo from '@/components/VehicleInfo.vue'
|
||||
import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue'
|
||||
|
|
@ -36,7 +37,6 @@ function handleLookup(lookedUpPlate: string) {
|
|||
|
||||
<template>
|
||||
<div class="home">
|
||||
<h1>BilHälsning</h1>
|
||||
<p class="home__subtitle">Skicka ett brev till en fordonsägare</p>
|
||||
|
||||
<PlateInput v-model="plate" @lookup="handleLookup" />
|
||||
|
|
@ -47,6 +47,14 @@ function handleLookup(lookedUpPlate: string) {
|
|||
:not-found="notFound"
|
||||
:plate="plate"
|
||||
/>
|
||||
|
||||
<RouterLink
|
||||
v-if="vehicle"
|
||||
:to="{ name: 'compose', query: { plate } }"
|
||||
class="home__cta"
|
||||
>
|
||||
Skicka ett brev till ägaren
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -61,4 +69,21 @@ function handleLookup(lookedUpPlate: string) {
|
|||
color: #718096;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.home__cta {
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
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 {
|
||||
background: #2f855a;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomePage from '@/pages/HomePage.vue'
|
||||
import ComposePage from '@/pages/ComposePage.vue'
|
||||
import AboutPage from '@/pages/AboutPage.vue'
|
||||
import ContactPage from '@/pages/ContactPage.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -9,6 +12,21 @@ const router = createRouter({
|
|||
name: 'home',
|
||||
component: HomePage,
|
||||
},
|
||||
{
|
||||
path: '/compose',
|
||||
name: 'compose',
|
||||
component: ComposePage,
|
||||
},
|
||||
{
|
||||
path: '/om',
|
||||
name: 'about',
|
||||
component: AboutPage,
|
||||
},
|
||||
{
|
||||
path: '/kontakt',
|
||||
name: 'contact',
|
||||
component: ContactPage,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
0
backend/gradlew → gradlew
vendored
0
backend/gradlew → gradlew
vendored
|
|
@ -1 +1,3 @@
|
|||
rootProject.name = 'bilhej'
|
||||
|
||||
include 'backend'
|
||||
Loading…
Reference in a new issue