Compare commits
No commits in common. "master" and "feature/account-settings-dropdown" have entirely different histories.
master
...
feature/ac
125 changed files with 936 additions and 6845 deletions
|
|
@ -1,66 +1,6 @@
|
|||
# Exclude everything that isn't strictly needed to build or run the dev images.
|
||||
# The dev Dockerfiles COPY source subpaths (frontend/, backend/, gradlew,
|
||||
# settings.gradle, etc.), so without this the image would bloat with docs,
|
||||
# scripts, git history, etc.
|
||||
|
||||
# Build artifacts and caches (mounted as named volumes at runtime)
|
||||
.gradle
|
||||
backend/build
|
||||
frontend/dist
|
||||
frontend/coverage
|
||||
frontend/node_modules
|
||||
backend/.gradle
|
||||
|
||||
# Test outputs
|
||||
**/build/test-results
|
||||
**/build/reports
|
||||
**/coverage
|
||||
**/.pytest_cache
|
||||
frontend/playwright-report
|
||||
frontend/test-results
|
||||
|
||||
# Local config and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/application-local.yml
|
||||
|
||||
# VCS and editor state
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.github
|
||||
.forgejo
|
||||
.idea
|
||||
.vscode
|
||||
*.iml
|
||||
.DS_Store
|
||||
|
||||
# Documentation (not needed at runtime)
|
||||
README.md
|
||||
REQUIREMENTS.md
|
||||
AGENTS.md
|
||||
CODING_GUIDELINES.md
|
||||
docs/
|
||||
|
||||
# Ops scripts (not needed at runtime)
|
||||
scripts/
|
||||
|
||||
# Test source dirs that aren't built into runtime artifacts
|
||||
frontend/node_modules
|
||||
backend/build
|
||||
frontend/src/__tests__
|
||||
backend/src/test
|
||||
|
||||
# Docker metadata — Dockerfiles, .dockerignore, and compose files are not
|
||||
# needed inside the running image. Keep docker/*.conf and docker/entrypoint.sh
|
||||
# because frontend.prod.Dockerfile copies them into the production nginx image.
|
||||
docker/*.Dockerfile
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
logs/
|
||||
tmp/
|
||||
*.bak
|
||||
*.tmp
|
||||
13
.env.example
13
.env.example
|
|
@ -24,12 +24,6 @@ STRIPE_WEBHOOK_SECRET=whsec_...
|
|||
STRIPE_PRICE_ID=price_...
|
||||
|
||||
# ---------- Swish (Phase 0) ----------
|
||||
# The Swish number customers pay to. Two formats accepted:
|
||||
# - Swedish phone number: 0701234567 (normalised to 46… for the payment URL)
|
||||
# - Swish Business number: 1234567890 (starts with 123, used as-is)
|
||||
# A Swish Business number (123…) is recommended — get one from your bank
|
||||
# via a "Swish Företag" agreement. No Swish Commerce API certificate needed;
|
||||
# the frontend generates a pre-filled QR code + payment link automatically.
|
||||
SWISH_NUMBER=0701234567
|
||||
|
||||
# ---------- App URL (password reset links in email) ----------
|
||||
|
|
@ -49,10 +43,3 @@ APP_PUBLIC_BASE_URL=http://localhost:3000
|
|||
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
|
||||
ADMIN_EMAIL=admin@bilhej.se
|
||||
ADMIN_PASSWORD=change_me_to_a_strong_password
|
||||
|
||||
# ---------- Umami analytics (production frontend build only) ----------
|
||||
# Baked into the frontend image at build time. Leave unset for local dev / docker compose up.
|
||||
# Website ID from https://analytics.bilhej.se → Settings → Websites → BilHej
|
||||
# See docs/umami-analytics.md
|
||||
# VITE_UMAMI_WEBSITE_ID=
|
||||
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)'
|
||||
required: false
|
||||
default: 'auto'
|
||||
description: 'Git tag to create for this deploy (e.g. v0.1.2) — not the branch/tag above'
|
||||
required: true
|
||||
default: 'v0.1.0'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
|
|
@ -21,36 +21,12 @@ jobs:
|
|||
git fetch --depth 1 origin ${GITHUB_SHA}
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
- name: Resolve version
|
||||
run: |
|
||||
INPUT_VERSION="${{ github.event.inputs.version }}"
|
||||
if [ -z "$INPUT_VERSION" ] || [ "$INPUT_VERSION" = "auto" ]; then
|
||||
git fetch --tags origin
|
||||
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
|
||||
if [ -z "$LATEST" ]; then LATEST="v0.0.0"; fi
|
||||
BASE="${LATEST#v}"
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3)
|
||||
PATCH=$(( ${PATCH:-0} + 1 ))
|
||||
VERSION="v${MAJOR:-0}.${MINOR:-0}.${PATCH}"
|
||||
echo "Latest tag: $LATEST → auto-bumped to $VERSION"
|
||||
else
|
||||
VERSION="$INPUT_VERSION"
|
||||
echo "Using manual version: $VERSION"
|
||||
fi
|
||||
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "ERROR: resolved version '$VERSION' is not valid semver (expected vX.Y.Z)"
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Tag version
|
||||
run: |
|
||||
git tag -d ${{ env.VERSION }} 2>/dev/null || true
|
||||
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true
|
||||
git tag ${{ env.VERSION }}
|
||||
git push origin ${{ env.VERSION }}
|
||||
git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true
|
||||
git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
|
||||
git tag ${{ github.event.inputs.version }}
|
||||
git push origin ${{ github.event.inputs.version }}
|
||||
|
||||
- name: Write production .env
|
||||
env:
|
||||
|
|
@ -70,7 +46,6 @@ jobs:
|
|||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
|
||||
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
||||
VITE_UMAMI_WEBSITE_ID: ${{ secrets.VITE_UMAMI_WEBSITE_ID }}
|
||||
run: |
|
||||
# Docker Compose treats $ as variable interpolation in .env files.
|
||||
# Escape literal dollar signs (e.g. in passwords) as $$.
|
||||
|
|
@ -92,7 +67,6 @@ jobs:
|
|||
printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")"
|
||||
printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")"
|
||||
printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")"
|
||||
printf 'VITE_UMAMI_WEBSITE_ID=%s\n' "$(escape "$VITE_UMAMI_WEBSITE_ID")"
|
||||
} > .env
|
||||
|
||||
- name: Build and start production stack
|
||||
|
|
@ -158,7 +132,7 @@ jobs:
|
|||
run: |
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════"
|
||||
echo " Deployed ${{ env.VERSION }} to production"
|
||||
echo " Deployed ${{ github.event.inputs.version }} to production"
|
||||
echo "═══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
docker compose -p bilhej-prod -f docker-compose.prod.yml ps
|
||||
|
|
|
|||
71
AGENTS.md
71
AGENTS.md
|
|
@ -74,14 +74,6 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe
|
|||
Flyway migrations run automatically on Spring Boot startup. Migration files
|
||||
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`.
|
||||
|
||||
**Before adding a migration:** run `./scripts/next-flyway-version.sh` and use that
|
||||
version. Never reuse a version number already on `master`. Never edit a migration
|
||||
after it has merged — add a new higher version instead. CI runs
|
||||
`scripts/check-flyway-migrations.sh` against `origin/master`.
|
||||
|
||||
If local dev Postgres fails with Flyway checksum / “migration not resolved locally”
|
||||
after switching branches, run `./gradlew reset` (wipes the Docker DB volume).
|
||||
|
||||
To reset: `docker compose down -v && docker compose up -d`.
|
||||
|
||||
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
|
||||
|
|
@ -160,26 +152,6 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
|
|||
list concrete changes as bullet points. Never write single-line
|
||||
"feat: add X" messages.
|
||||
|
||||
**Before every commit (mandatory — agents must not skip):**
|
||||
|
||||
```bash
|
||||
# from repo root; needs Docker running
|
||||
export POSTGRES_DB=bilhej POSTGRES_USER=bilhej POSTGRES_PASSWORD=test_pw_ci_123
|
||||
export JWT_SECRET=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PRICE_ID=price_fake
|
||||
./gradlew check
|
||||
```
|
||||
|
||||
This runs frontend lint, frontend unit tests, backend tests, coverage
|
||||
thresholds, Flyway checks, and **all E2E tests in Docker**. **Do not commit or
|
||||
push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
|
||||
(runs the same `check` on every `git commit`).
|
||||
|
||||
**Note for agents:** The pre-commit hook runs the full `./gradlew check` which
|
||||
takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on
|
||||
agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run
|
||||
`./gradlew check` manually before committing.
|
||||
|
||||
### Frontend (Vue.js 3)
|
||||
- `<script setup>` with Composition API only. Never Options API.
|
||||
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.
|
||||
|
|
@ -215,9 +187,6 @@ After the address is used to mail the letter, it must be deleted. The Order
|
|||
entity must NOT have an address field. The address lookup and mailing are
|
||||
external/human processes in Phase 0.
|
||||
|
||||
### E2E must use Docker (not host Playwright)
|
||||
See **Testing Approach → E2E (Playwright) — Docker only** above. Do not run `npx playwright install` or `npm run test:e2e` on the host when verifying E2E.
|
||||
|
||||
### Local email (Mailpit)
|
||||
`docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses Resend SMTP—see docs/production-email-checklist.md.
|
||||
|
||||
|
|
@ -259,40 +228,12 @@ the same PR — never merge code without corresponding tests.
|
|||
- Component tests with Vue Test Utils where needed.
|
||||
- E2E tests with Playwright in `frontend/e2e/`.
|
||||
|
||||
### E2E (Playwright) — **Docker only**
|
||||
|
||||
**Agents and humans: never run Playwright on the host.**
|
||||
|
||||
| Do **not** run | Why |
|
||||
|----------------|-----|
|
||||
| `npx playwright test` | Wrong environment; needs Docker stack |
|
||||
| `npm run test:e2e` | Same — host Playwright, not supported for agents |
|
||||
| `npx playwright install` | Do not install browsers on the host; the Playwright image already includes them |
|
||||
|
||||
**Always use Docker** (`docker-compose.e2e.yml`): isolated postgres (tmpfs), backend, frontend, Mailpit, and the official Playwright container on the `e2e` network (`PLAYWRIGHT_BASE_URL=http://frontend`).
|
||||
|
||||
**Full E2E suite** (same as Forgejo CI / `./gradlew check`):
|
||||
|
||||
```bash
|
||||
# from repo root — set env (or use .env; see .env.example)
|
||||
cd frontend && npm run test:e2e:ci
|
||||
# equivalent:
|
||||
./gradlew frontendE2E
|
||||
```
|
||||
|
||||
**Single spec or project** (stack must be reachable on the `e2e` network):
|
||||
|
||||
```bash
|
||||
# from repo root, after exporting the same vars as frontendE2E / .env
|
||||
docker compose -f docker-compose.e2e.yml up -d --build postgres mailpit backend frontend
|
||||
docker compose -f docker-compose.e2e.yml run --rm --build playwright \
|
||||
sh -c 'npx playwright test admin-fulfillment.spec.ts --project=chromium-serial --reporter=list'
|
||||
docker compose -f docker-compose.e2e.yml down
|
||||
```
|
||||
|
||||
- Config: `frontend/playwright.config.ts`
|
||||
- Tests: `frontend/e2e/*.spec.ts`
|
||||
- Serial specs (shared Mailpit / seeded DB): `admin-fulfillment`, `deferred-payment-admin`, `admin-dashboard`, `account-settings`, `password-reset` — project `chromium-serial` runs **after** parallel `chromium`, `workers: 1`
|
||||
### E2E (Playwright)
|
||||
- `npm run test:e2e` — runs all Playwright tests (headless Chromium).
|
||||
- Requires `docker compose up` (backend + frontend running).
|
||||
- Config: `frontend/playwright.config.ts`.
|
||||
- Tests: `frontend/e2e/*.spec.ts`.
|
||||
- Docker CI: `npm run test:e2e:ci` — runs tests inside official Playwright Docker container. Starts postgres, backend, frontend, and Playwright via `docker-compose.ci.yml`. Use for consistent environment or CI pipelines.
|
||||
|
||||
### CI (future)
|
||||
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.
|
||||
|
|
|
|||
|
|
@ -342,7 +342,6 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
|
|||
| `MAIL_USERNAME` | `resend` (literal string) |
|
||||
| `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) |
|
||||
| `MAIL_FROM` | `noreply@bilhej.se` (must be on verified domain) |
|
||||
| `VITE_UMAMI_WEBSITE_ID` | Umami website UUID for `bilhej.se` (see `docs/umami-analytics.md`) |
|
||||
|
||||
Passwords may contain `$` — the deploy workflow escapes these for Docker Compose.
|
||||
Production does **not** seed `test@bilhej.se` or demo orders. On first start, the
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
package se.bilhalsning.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
|
|
@ -14,7 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import se.bilhalsning.dto.ErrorResponse;
|
||||
import se.bilhalsning.security.JwtAuthenticationFilter;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
|
||||
|
|
@ -22,13 +17,6 @@ import se.bilhalsning.security.JwtService;
|
|||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
static final String UNAUTHENTICATED_MESSAGE =
|
||||
"Din session har löpt ut eller är ogiltig. Logga in igen.";
|
||||
static final String FORBIDDEN_MESSAGE =
|
||||
"Du har inte behörighet att utföra denna åtgärd.";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
|
|
@ -55,25 +43,11 @@ public class SecurityConfig {
|
|||
.permitAll()
|
||||
.requestMatchers("/api/webhooks/**").permitAll()
|
||||
.requestMatchers("/api/payment/swish-info").permitAll()
|
||||
.requestMatchers("/api/guest-orders/**").permitAll()
|
||||
.requestMatchers("/api/vehicles/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(eh -> eh
|
||||
.authenticationEntryPoint((request, response, ex) ->
|
||||
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
|
||||
.accessDeniedHandler((request, response, ex) ->
|
||||
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private void writeError(HttpServletResponse response, HttpStatus status, String message)
|
||||
throws java.io.IOException {
|
||||
response.setStatus(status.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,10 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.AdminOrderMapper;
|
||||
import se.bilhalsning.dto.AdminOrderResponse;
|
||||
import se.bilhalsning.dto.RegisterShipmentRequest;
|
||||
import se.bilhalsning.dto.UpdateAdminNotesRequest;
|
||||
import se.bilhalsning.dto.UpdateStatusRequest;
|
||||
import se.bilhalsning.dto.UpdateTrackingRequest;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.service.AdminOrderWorkflowService;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
|
||||
import java.util.List;
|
||||
|
|
@ -27,12 +24,11 @@ import java.util.UUID;
|
|||
public class AdminController {
|
||||
|
||||
private final OrderService orderService;
|
||||
private final AdminOrderWorkflowService adminOrderWorkflowService;
|
||||
|
||||
@GetMapping("/orders")
|
||||
public ResponseEntity<List<AdminOrderResponse>> listAllOrders() {
|
||||
List<AdminOrderResponse> orders = orderService.getAllOrders().stream()
|
||||
.map(AdminOrderMapper::toResponse)
|
||||
.map(this::toAdminResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(orders);
|
||||
}
|
||||
|
|
@ -41,26 +37,29 @@ public class AdminController {
|
|||
public ResponseEntity<AdminOrderResponse> updateStatus(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateStatusRequest request) {
|
||||
Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status());
|
||||
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||
Order order = orderService.updateOrderStatus(id, request.status());
|
||||
return ResponseEntity.ok(toAdminResponse(order));
|
||||
}
|
||||
|
||||
@PatchMapping("/orders/{id}/register-shipment")
|
||||
public ResponseEntity<AdminOrderResponse> registerShipment(
|
||||
@PatchMapping("/orders/{id}")
|
||||
public ResponseEntity<AdminOrderResponse> updateTracking(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody RegisterShipmentRequest request) {
|
||||
Order order = adminOrderWorkflowService.registerShipment(
|
||||
id,
|
||||
request.trackingInput(),
|
||||
request.notifyCustomerOrDefault());
|
||||
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||
@Valid @RequestBody UpdateTrackingRequest request) {
|
||||
Order order = orderService.updateTracking(id, request.trackingId());
|
||||
return ResponseEntity.ok(toAdminResponse(order));
|
||||
}
|
||||
|
||||
@PatchMapping("/orders/{id}/notes")
|
||||
public ResponseEntity<AdminOrderResponse> updateNotes(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody UpdateAdminNotesRequest request) {
|
||||
Order order = adminOrderWorkflowService.updateAdminNotes(id, request.adminNotes());
|
||||
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||
private AdminOrderResponse toAdminResponse(Order order) {
|
||||
String email = order.getUser() != null ? order.getUser().getEmail() : "";
|
||||
return new AdminOrderResponse(
|
||||
order.getId(),
|
||||
email,
|
||||
order.getPlate(),
|
||||
order.getLetterText(),
|
||||
order.getStatus().getValue(),
|
||||
order.getTrackingId(),
|
||||
order.getAmountPaid(),
|
||||
order.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.CreateGuestOrderRequest;
|
||||
import se.bilhalsning.dto.GuestOrderResponse;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Public (no-JWT) endpoints for placing and paying for orders without an
|
||||
* account — guest checkout.
|
||||
*
|
||||
* Auth: white-listed in {@code SecurityConfig} so no JWT filter runs on
|
||||
* these paths. The guest token (UUID v4, generated at order create time
|
||||
* in {@link Order#onCreate()}) is the only credential the client holds
|
||||
* and must pass as a path variable. Token brute-force resistance: 122
|
||||
* bits of entropy.
|
||||
*
|
||||
* Routes parallel {@link OrderController} deliberately so the JWT path
|
||||
* stays clean and unmodified.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/guest-orders")
|
||||
@RequiredArgsConstructor
|
||||
public class GuestOrderController {
|
||||
|
||||
private final OrderService orderService;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<GuestOrderResponse> create(
|
||||
@Valid @RequestBody CreateGuestOrderRequest request) {
|
||||
Order order = orderService.createGuestOrder(
|
||||
request.plate(),
|
||||
request.letterText(),
|
||||
request.email()
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
|
||||
}
|
||||
|
||||
@GetMapping("/{token}")
|
||||
public ResponseEntity<GuestOrderResponse> get(@PathVariable UUID token) {
|
||||
Order order = orderService.getOrderByGuestToken(token);
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
@PostMapping("/{token}/pay")
|
||||
public ResponseEntity<GuestOrderResponse> pay(@PathVariable UUID token) {
|
||||
Order order = orderService.confirmGuestPayment(token);
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
private GuestOrderResponse toResponse(Order order) {
|
||||
return new GuestOrderResponse(
|
||||
order.getId(),
|
||||
order.getPlate(),
|
||||
order.getLetterText(),
|
||||
order.getStatus().getValue(),
|
||||
order.getTrackingId(),
|
||||
order.getAmountPaid(),
|
||||
order.getCreatedAt(),
|
||||
order.getGuestToken()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.service.AdminOrderStatusRules;
|
||||
|
||||
public final class AdminOrderMapper {
|
||||
|
||||
private AdminOrderMapper() {}
|
||||
|
||||
public static AdminOrderResponse toResponse(Order order) {
|
||||
String email = order.getUser() != null ? order.getUser().getEmail() : "";
|
||||
return new AdminOrderResponse(
|
||||
order.getId(),
|
||||
email,
|
||||
order.getPlate(),
|
||||
order.getLetterText(),
|
||||
order.getStatus().getValue(),
|
||||
order.getTrackingId(),
|
||||
order.getAmountPaid(),
|
||||
order.getShippedAt(),
|
||||
order.getAdminNotes(),
|
||||
order.getCreatedAt(),
|
||||
AdminOrderStatusRules.allowedStatusValues(order),
|
||||
AdminOrderStatusRules.canRegisterShipment(order));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package se.bilhalsning.dto;
|
|||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AdminOrderResponse(
|
||||
|
|
@ -13,9 +12,5 @@ public record AdminOrderResponse(
|
|||
String status,
|
||||
String trackingId,
|
||||
BigDecimal amountPaid,
|
||||
Instant shippedAt,
|
||||
String adminNotes,
|
||||
Instant createdAt,
|
||||
List<String> allowedStatuses,
|
||||
boolean canRegisterShipment
|
||||
Instant createdAt
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Create an order without an account (guest checkout).
|
||||
*
|
||||
* The {@code plate} validation mirrors {@link CreateOrderRequest} so the
|
||||
* guest path accepts the same Swedish 3-letter + 3-char plate format.
|
||||
*/
|
||||
public record CreateGuestOrderRequest(
|
||||
@NotBlank(message = "Registreringsnummer krävs")
|
||||
@Pattern(regexp = "^[A-Za-z]{3}\\d{2}[A-Za-z0-9]$", message = "Ogiltigt registreringsnummer")
|
||||
String plate,
|
||||
|
||||
@NotBlank(message = "Brevtext krävs")
|
||||
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
|
||||
String letterText,
|
||||
|
||||
@NotBlank(message = "E-post krävs")
|
||||
@Email(message = "Ogiltig e-postadress")
|
||||
@Size(max = 255, message = "E-postadressen är för lång")
|
||||
String email
|
||||
) {}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Response shape for guest-order endpoints.
|
||||
*
|
||||
* Identical to {@link OrderResponse} plus {@link #guestToken}, which the
|
||||
* client needs to (a) build the payment page URL and (b) look up the
|
||||
* order again without an account. Token is returned only on create and
|
||||
* by-token lookup — it is never re-exposed by order ID alone.
|
||||
*/
|
||||
public record GuestOrderResponse(
|
||||
UUID id,
|
||||
String plate,
|
||||
String letterText,
|
||||
String status,
|
||||
String trackingId,
|
||||
BigDecimal amountPaid,
|
||||
Instant createdAt,
|
||||
UUID guestToken
|
||||
) {}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record RegisterShipmentRequest(
|
||||
@NotBlank(message = "Spårnings-ID krävs")
|
||||
String trackingInput,
|
||||
Boolean notifyCustomer
|
||||
) {
|
||||
public boolean notifyCustomerOrDefault() {
|
||||
return notifyCustomer == null || notifyCustomer;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record UpdateAdminNotesRequest(
|
||||
String adminNotes
|
||||
) {}
|
||||
|
|
@ -6,7 +6,7 @@ import jakarta.validation.constraints.Pattern;
|
|||
public record UpdateStatusRequest(
|
||||
@NotBlank(message = "Status krävs")
|
||||
@Pattern(
|
||||
regexp = "pending_payment|paid|processing|sent|delivered|failed|cancelled",
|
||||
regexp = "pending_payment|paid|processing|sent|delivered|failed",
|
||||
message = "Ogiltig status"
|
||||
)
|
||||
String status
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record UpdateTrackingRequest(
|
||||
String trackingId
|
||||
) {}
|
||||
|
|
@ -21,12 +21,7 @@ public class Order {
|
|||
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
/**
|
||||
* Null for guest orders (no registered user). FK to {@code users(id)}
|
||||
* is still in place — NULL is FK-legal. Either {@code userId} or
|
||||
* {@code guestToken} is set; never both, never neither.
|
||||
*/
|
||||
@Column(name = "user_id", columnDefinition = "uuid")
|
||||
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
|
||||
private UUID userId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
|
|
@ -48,43 +43,17 @@ public class Order {
|
|||
@Column(name = "tracking_id", length = 100)
|
||||
private String trackingId;
|
||||
|
||||
@Column(name = "shipped_at")
|
||||
private Instant shippedAt;
|
||||
|
||||
@Column(name = "admin_notes", columnDefinition = "text")
|
||||
private String adminNotes;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
/**
|
||||
* Guest contact email. Stored at order create time so a magic link
|
||||
* (carrying {@code guestToken}) can be emailed to the customer later.
|
||||
*/
|
||||
@Column(name = "guest_email", length = 255)
|
||||
private String guestEmail;
|
||||
|
||||
/**
|
||||
* Opaque UUID v4 token. The only credential a guest holds. Used as
|
||||
* the path variable on all {@code /api/guest-orders/{token}/...}
|
||||
* endpoints. Generated in {@link #onCreate()} for guest orders only.
|
||||
*/
|
||||
@Column(name = "guest_token", columnDefinition = "uuid")
|
||||
private UUID guestToken;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (this.id == null) {
|
||||
this.id = UUID.randomUUID();
|
||||
}
|
||||
// Guest orders (no userId) get a token for unauthenticated lookup.
|
||||
// User-owned orders never get one — they go through JWT + userId.
|
||||
if (this.guestToken == null && this.userId == null) {
|
||||
this.guestToken = UUID.randomUUID();
|
||||
}
|
||||
Instant now = Instant.now();
|
||||
if (this.createdAt == null) {
|
||||
this.createdAt = now;
|
||||
|
|
@ -161,22 +130,6 @@ public class Order {
|
|||
this.trackingId = trackingId;
|
||||
}
|
||||
|
||||
public Instant getShippedAt() {
|
||||
return shippedAt;
|
||||
}
|
||||
|
||||
public void setShippedAt(Instant shippedAt) {
|
||||
this.shippedAt = shippedAt;
|
||||
}
|
||||
|
||||
public String getAdminNotes() {
|
||||
return adminNotes;
|
||||
}
|
||||
|
||||
public void setAdminNotes(String adminNotes) {
|
||||
this.adminNotes = adminNotes;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
|
@ -184,20 +137,4 @@ public class Order {
|
|||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public String getGuestEmail() {
|
||||
return guestEmail;
|
||||
}
|
||||
|
||||
public void setGuestEmail(String guestEmail) {
|
||||
this.guestEmail = guestEmail;
|
||||
}
|
||||
|
||||
public UUID getGuestToken() {
|
||||
return guestToken;
|
||||
}
|
||||
|
||||
public void setGuestToken(UUID guestToken) {
|
||||
this.guestToken = guestToken;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import se.bilhalsning.entity.Order;
|
|||
import se.bilhalsning.entity.OrderStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
|
|
@ -18,11 +17,4 @@ public interface OrderRepository extends JpaRepository<Order, UUID> {
|
|||
|
||||
@EntityGraph(attributePaths = {"user"})
|
||||
List<Order> findAllByOrderByCreatedAtDesc();
|
||||
|
||||
@EntityGraph(attributePaths = {"user"})
|
||||
Optional<Order> findWithUserById(UUID id);
|
||||
|
||||
// Guest checkout — looks up by opaque token, no JWT. Partial-unique
|
||||
// index on (guest_token) WHERE NOT NULL enforces uniqueness on writes.
|
||||
Optional<Order> findByGuestToken(UUID guestToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public class JwtService {
|
|||
this(secret, DEFAULT_EXPIRATION_MS);
|
||||
}
|
||||
|
||||
public JwtService(String secret, long expirationMs) {
|
||||
JwtService(String secret, long expirationMs) {
|
||||
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
this.expirationMs = expirationMs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
|
||||
/**
|
||||
* Admin status transitions and UI affordances. Single source of truth for
|
||||
* {@link AdminOrderResponse#allowedStatuses()} and {@link AdminOrderResponse#canRegisterShipment()}.
|
||||
*/
|
||||
public final class AdminOrderStatusRules {
|
||||
|
||||
private AdminOrderStatusRules() {}
|
||||
|
||||
public static List<String> allowedStatusValues(Order order) {
|
||||
OrderStatus current = order.getStatus();
|
||||
LinkedHashSet<OrderStatus> options = new LinkedHashSet<>();
|
||||
options.add(current);
|
||||
for (OrderStatus target : allowedTargets(current, order)) {
|
||||
options.add(target);
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
for (OrderStatus status : options) {
|
||||
values.add(status.getValue());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
public static boolean canRegisterShipment(Order order) {
|
||||
OrderStatus status = order.getStatus();
|
||||
if (status == OrderStatus.PROCESSING
|
||||
|| status == OrderStatus.SENT
|
||||
|| status == OrderStatus.DELIVERED) {
|
||||
return true;
|
||||
}
|
||||
return status == OrderStatus.FAILED && order.getAmountPaid() != null;
|
||||
}
|
||||
|
||||
public static void validateTransition(Order order, OrderStatus to) {
|
||||
OrderStatus from = order.getStatus();
|
||||
if (from == to) {
|
||||
return;
|
||||
}
|
||||
if (!allowedTargets(from, order).contains(to)) {
|
||||
throw new InvalidOrderStateException(
|
||||
"Status kan inte ändras från " + from.getValue() + " till " + to.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<OrderStatus> allowedTargets(OrderStatus from, Order order) {
|
||||
return switch (from) {
|
||||
case PENDING_PAYMENT -> List.of(OrderStatus.FAILED);
|
||||
case PROCESSING -> List.of(OrderStatus.FAILED);
|
||||
case SENT -> List.of(OrderStatus.DELIVERED, OrderStatus.FAILED);
|
||||
case DELIVERED -> List.of(OrderStatus.FAILED);
|
||||
case FAILED -> allowedTargetsFromFailed(order);
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
private static List<OrderStatus> allowedTargetsFromFailed(Order order) {
|
||||
if (hasTrackingId(order)) {
|
||||
return List.of(
|
||||
OrderStatus.PROCESSING,
|
||||
OrderStatus.SENT,
|
||||
OrderStatus.DELIVERED);
|
||||
}
|
||||
if (order.getAmountPaid() == null) {
|
||||
return List.of(OrderStatus.PENDING_PAYMENT);
|
||||
}
|
||||
return List.of(OrderStatus.PROCESSING, OrderStatus.SENT);
|
||||
}
|
||||
|
||||
private static boolean hasTrackingId(Order order) {
|
||||
String trackingId = order.getTrackingId();
|
||||
return trackingId != null && !trackingId.isBlank();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
import se.bilhalsning.util.PostNordTrackingNormalizer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminOrderWorkflowService {
|
||||
|
||||
private final OrderRepository orderRepository;
|
||||
private final OrderNotificationService orderNotificationService;
|
||||
|
||||
public Order updateOrderStatus(UUID orderId, String statusString) {
|
||||
Order order = requireOrder(orderId);
|
||||
OrderStatus newStatus = parseStatus(statusString);
|
||||
OrderStatus previousStatus = order.getStatus();
|
||||
AdminOrderStatusRules.validateTransition(order, newStatus);
|
||||
order.setStatus(newStatus);
|
||||
if (newStatus == OrderStatus.SENT
|
||||
&& previousStatus == OrderStatus.FAILED
|
||||
&& order.getShippedAt() == null) {
|
||||
order.setShippedAt(Instant.now());
|
||||
}
|
||||
Order saved = orderRepository.save(order);
|
||||
if (newStatus == OrderStatus.FAILED && previousStatus != OrderStatus.FAILED) {
|
||||
orderNotificationService.notifyOrderFailed(saved);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) {
|
||||
String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput);
|
||||
Order order = requireOrder(orderId);
|
||||
OrderStatus previousStatus = order.getStatus();
|
||||
|
||||
if (!AdminOrderStatusRules.canRegisterShipment(order)) {
|
||||
throw new InvalidOrderStateException(
|
||||
"Beställningen kan inte registreras som utskickad i detta tillstånd");
|
||||
}
|
||||
|
||||
if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) {
|
||||
throw new InvalidOrderStateException(
|
||||
"Obetalda misslyckade beställningar kan inte registreras som utskickade");
|
||||
}
|
||||
|
||||
boolean firstShipment = previousStatus == OrderStatus.PROCESSING
|
||||
|| previousStatus == OrderStatus.FAILED;
|
||||
order.setTrackingId(trackingId);
|
||||
if (firstShipment) {
|
||||
order.setStatus(OrderStatus.SENT);
|
||||
order.setShippedAt(Instant.now());
|
||||
}
|
||||
|
||||
Order saved = orderRepository.save(order);
|
||||
if (notifyCustomer && firstShipment) {
|
||||
orderNotificationService.notifyOrderSent(saved, trackingId);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Order updateAdminNotes(UUID orderId, String adminNotes) {
|
||||
Order order = requireOrder(orderId);
|
||||
order.setAdminNotes(adminNotes);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
private Order requireOrder(UUID orderId) {
|
||||
return orderRepository.findWithUserById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
}
|
||||
|
||||
private static OrderStatus parseStatus(String statusString) {
|
||||
try {
|
||||
return OrderStatus.valueOf(statusString.toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new IllegalArgumentException("Ogiltig status");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,71 +93,4 @@ public class EmailService {
|
|||
throw new IllegalStateException("Kunde inte skicka e-post just nu");
|
||||
}
|
||||
}
|
||||
|
||||
public void sendOrderProcessingEmail(String toEmail, String plate, String ordersUrl) {
|
||||
String subject = "Din beställning hanteras – BilHej";
|
||||
String body = """
|
||||
Hej,
|
||||
|
||||
Tack för din betalning! Vi har tagit emot din beställning för fordonet %s och börjar hantera brevet.
|
||||
|
||||
Du kan följa status på dina beställningar här:
|
||||
%s
|
||||
|
||||
Vänliga hälsningar,
|
||||
BilHej
|
||||
""".formatted(plate, ordersUrl);
|
||||
sendPlainText(toEmail, subject, body);
|
||||
}
|
||||
|
||||
public void sendOrderSentEmail(String toEmail, String plate, String trackingId, String trackingUrl) {
|
||||
String subject = "Ditt brev är skickat – BilHej";
|
||||
String body = """
|
||||
Hej,
|
||||
|
||||
Ditt brev till fordonet %s har skickats med PostNord.
|
||||
|
||||
Spårnings-ID: %s
|
||||
Spåra brevet: %s
|
||||
|
||||
Vänliga hälsningar,
|
||||
BilHej
|
||||
""".formatted(plate, trackingId, trackingUrl);
|
||||
sendPlainText(toEmail, subject, body);
|
||||
}
|
||||
|
||||
public void sendOrderFailedEmail(String toEmail, String plate, String ordersUrl) {
|
||||
String subject = "Din beställning kunde inte slutföras – BilHej";
|
||||
String body = """
|
||||
Hej,
|
||||
|
||||
Tyvärr kunde vi inte slutföra din beställning för fordonet %s. Vi återkommer om återbetalning behövs.
|
||||
|
||||
Se dina beställningar här:
|
||||
%s
|
||||
|
||||
Vänliga hälsningar,
|
||||
BilHej
|
||||
""".formatted(plate, ordersUrl);
|
||||
sendPlainText(toEmail, subject, body);
|
||||
}
|
||||
|
||||
private void sendPlainText(String toEmail, String subject, String body) {
|
||||
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
|
||||
log.info("SMTP not configured. Email to {} — subject: {}", toEmail, subject);
|
||||
return;
|
||||
}
|
||||
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(mailFrom);
|
||||
message.setTo(toEmail);
|
||||
message.setSubject(subject);
|
||||
message.setText(body);
|
||||
try {
|
||||
mailSender.send(message);
|
||||
} catch (MailException ex) {
|
||||
log.error("Failed to send email to {}", toEmail, ex);
|
||||
throw new IllegalStateException("Kunde inte skicka e-post just nu");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.repository.UserRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderNotificationService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
@Value("${app.public-base-url:http://localhost:3000}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
public void notifyOrderProcessing(Order order) {
|
||||
String email = resolveCustomerEmail(order);
|
||||
if (email.isBlank()) {
|
||||
return;
|
||||
}
|
||||
emailService.sendOrderProcessingEmail(
|
||||
email,
|
||||
order.getPlate(),
|
||||
ordersPageUrl());
|
||||
}
|
||||
|
||||
public void notifyOrderSent(Order order, String trackingId) {
|
||||
String email = resolveCustomerEmail(order);
|
||||
if (email.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String trackingUrl = "https://www.postnord.se/verktyg/spara/?id=" + trackingId;
|
||||
emailService.sendOrderSentEmail(email, order.getPlate(), trackingId, trackingUrl);
|
||||
}
|
||||
|
||||
public void notifyOrderFailed(Order order) {
|
||||
String email = resolveCustomerEmail(order);
|
||||
if (email.isBlank()) {
|
||||
return;
|
||||
}
|
||||
emailService.sendOrderFailedEmail(email, order.getPlate(), ordersPageUrl());
|
||||
}
|
||||
|
||||
private String resolveCustomerEmail(Order order) {
|
||||
if (order.getUser() != null && order.getUser().getEmail() != null) {
|
||||
return order.getUser().getEmail();
|
||||
}
|
||||
UUID userId = order.getUserId();
|
||||
if (userId == null) {
|
||||
return "";
|
||||
}
|
||||
return userRepository.findById(userId)
|
||||
.map(User::getEmail)
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private String ordersPageUrl() {
|
||||
String base = publicBaseUrl.endsWith("/")
|
||||
? publicBaseUrl.substring(0, publicBaseUrl.length() - 1)
|
||||
: publicBaseUrl;
|
||||
return base + "/mina-bestallningar";
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@ import java.util.UUID;
|
|||
public class OrderService {
|
||||
|
||||
private final OrderRepository orderRepository;
|
||||
private final OrderNotificationService orderNotificationService;
|
||||
|
||||
public Order createOrder(UUID userId, String plate, String letterText) {
|
||||
Order order = new Order();
|
||||
|
|
@ -27,56 +26,6 @@ public class OrderService {
|
|||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest checkout — creates an order with no registered user. The
|
||||
* {@code guestToken} is generated in {@link Order#onCreate()} so the
|
||||
* caller does not need to handle token creation logic. Returned
|
||||
* order's {@link Order#getGuestToken()} is the only credential the
|
||||
* client receives.
|
||||
*/
|
||||
public Order createGuestOrder(String plate, String letterText, String email) {
|
||||
Order order = new Order();
|
||||
// userId stays null — guest order. guestToken auto-generated in PrePersist.
|
||||
order.setGuestEmail(email == null ? null : email.trim().toLowerCase());
|
||||
order.setPlate(plate.toUpperCase().trim());
|
||||
order.setLetterText(letterText);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest-path order lookup by opaque token. Never exposes user-owned
|
||||
* orders via this path: if a guest token resolves to an order with a
|
||||
* non-null {@code userId} (corrupted data, manual SQL insert, etc.),
|
||||
* treat as not-found rather than leak the order.
|
||||
*/
|
||||
public Order getOrderByGuestToken(UUID guestToken) {
|
||||
Order order = orderRepository.findByGuestToken(guestToken)
|
||||
.orElseThrow(() -> new OrderNotFoundException(guestToken));
|
||||
if (order.getUserId() != null) {
|
||||
// Token points at a user-owned order — refuse to serve it.
|
||||
throw new OrderNotFoundException(guestToken);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Honor-system payment confirmation for guest orders. Mirrors
|
||||
* {@link #confirmPayment(UUID, UUID)} but authenticates via the
|
||||
* guest token instead of {@code userId}.
|
||||
*/
|
||||
public Order confirmGuestPayment(UUID guestToken) {
|
||||
Order order = getOrderByGuestToken(guestToken);
|
||||
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
|
||||
throw new InvalidOrderStateException(
|
||||
"Beställningen kan inte ändras i detta tillstånd");
|
||||
}
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
Order saved = orderRepository.save(order);
|
||||
orderNotificationService.notifyOrderProcessing(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public List<Order> getOrdersByUserId(UUID userId) {
|
||||
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
}
|
||||
|
|
@ -90,12 +39,27 @@ public class OrderService {
|
|||
return orderRepository.findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
|
||||
public Order updateOrderStatus(UUID orderId, String statusString) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
|
||||
OrderStatus newStatus = OrderStatus.valueOf(statusString.toUpperCase());
|
||||
order.setStatus(newStatus);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public Order updateTracking(UUID orderId, String trackingId) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
|
||||
order.setTrackingId(trackingId);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public Order confirmPayment(UUID orderId, UUID userId) {
|
||||
Order order = requirePendingOwnedBy(orderId, userId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
Order saved = orderRepository.save(order);
|
||||
orderNotificationService.notifyOrderProcessing(saved);
|
||||
return saved;
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public Order cancelOrder(UUID orderId, UUID userId) {
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
package se.bilhalsning.util;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class PostNordTrackingNormalizer {
|
||||
|
||||
private PostNordTrackingNormalizer() {
|
||||
}
|
||||
|
||||
public static String normalize(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException("Spårnings-ID krävs");
|
||||
}
|
||||
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.toLowerCase().contains("postnord")) {
|
||||
String fromUrl = extractIdFromPostNordUrl(trimmed);
|
||||
if (fromUrl != null && !fromUrl.isBlank()) {
|
||||
trimmed = fromUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed.replaceAll("\\s+", "");
|
||||
}
|
||||
|
||||
private static String extractIdFromPostNordUrl(String url) {
|
||||
try {
|
||||
URI uri = URI.create(url);
|
||||
String query = uri.getQuery();
|
||||
if (query == null) {
|
||||
return null;
|
||||
}
|
||||
for (String param : query.split("&")) {
|
||||
if (param.startsWith("id=")) {
|
||||
return URLDecoder.decode(param.substring(3), StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
-- Dev/CI: order in "processing" for admin fulfillment testing
|
||||
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
|
||||
VALUES (
|
||||
'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14',
|
||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
'JKL012',
|
||||
'Hej! Bara en påminnelse om serviceboken.',
|
||||
'processing',
|
||||
49.00,
|
||||
NULL,
|
||||
TIMESTAMP '2026-05-16 09:00:00',
|
||||
TIMESTAMP '2026-05-16 09:00:00'
|
||||
);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
ALTER TABLE orders ADD COLUMN admin_notes TEXT;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
-- Allows orders without a registered user (guest checkout).
|
||||
-- Users can place and pay for letters without creating an account.
|
||||
--
|
||||
-- user_id: previously NOT NULL - drop the constraint so guest orders
|
||||
-- can be created without a registered user. The FK stays in
|
||||
-- place (NULL user_id is FK-legal).
|
||||
-- guest_email: contact address for the guest. Used to send the magic
|
||||
-- link that lets them revisit their order status.
|
||||
-- guest_token: opaque UUID v4 - the only credential a guest has. Acts
|
||||
-- as their session token for order lookup + payment confirm.
|
||||
|
||||
ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL;
|
||||
ALTER TABLE orders ADD COLUMN guest_email VARCHAR(255);
|
||||
ALTER TABLE orders ADD COLUMN guest_token UUID;
|
||||
|
||||
-- Unique index on guest_token. Both H2 (tests/dev) and PostgreSQL (prod)
|
||||
-- treat NULLs as distinct in a UNIQUE index, so user-owned orders (which
|
||||
-- have a NULL token) never collide, while non-NULL guest tokens are
|
||||
-- enforced unique. A plain index is used instead of a partial
|
||||
-- (WHERE guest_token IS NOT NULL) index because H2 does not support
|
||||
-- partial indexes, and the plain form preserves the intended semantics.
|
||||
CREATE UNIQUE INDEX idx_orders_guest_token
|
||||
ON orders(guest_token);
|
||||
CREATE INDEX idx_orders_guest_email
|
||||
ON orders(guest_email);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
|
|
@ -23,9 +23,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.service.AdminOrderWorkflowService;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
|
||||
@SpringBootTest
|
||||
|
|
@ -38,22 +36,17 @@ class AdminControllerTest {
|
|||
@MockitoBean
|
||||
private OrderService orderService;
|
||||
|
||||
@MockitoBean
|
||||
private AdminOrderWorkflowService adminOrderWorkflowService;
|
||||
|
||||
@Test
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
||||
void shouldReturn403ForNonAdminUser() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -68,96 +61,151 @@ class AdminControllerTest {
|
|||
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
|
||||
.andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
|
||||
.andExpect(jsonPath("$[0].plate").value("ABC123"))
|
||||
.andExpect(jsonPath("$[0].status").value("sent"))
|
||||
.andExpect(jsonPath("$[0].allowedStatuses").isArray())
|
||||
.andExpect(jsonPath("$[0].canRegisterShipment").value(true));
|
||||
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
|
||||
.andExpect(jsonPath("$[0].status").value("sent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturnEmptyArrayWhenNoOrders() throws Exception {
|
||||
when(orderService.getAllOrders()).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenPatchingStatusWithoutAuth() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"paid\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
||||
void shouldReturn403WhenPatchingStatusAsNonAdmin() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"paid\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldUpdateOrderStatusSuccessfully() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.FAILED);
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PAID);
|
||||
|
||||
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("failed")))
|
||||
.thenReturn(order);
|
||||
when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"failed\"}"))
|
||||
.content("{\"status\":\"paid\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("failed"));
|
||||
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.status").value("paid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturn409WhenStatusTransitionInvalid() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("delivered")))
|
||||
.thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
||||
void shouldReturn400WhenStatusIsInvalid() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"delivered\"}"))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldRegisterShipmentSuccessfully() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
|
||||
order.setTrackingId("PN123456789");
|
||||
order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z"));
|
||||
|
||||
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
|
||||
.thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingInput\":\"PN123456789\",\"notifyCustomer\":true}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.trackingId").value("PN123456789"))
|
||||
.andExpect(jsonPath("$.status").value("sent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturn400WhenTrackingInputBlank() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingInput\":\"\"}"))
|
||||
.content("{\"status\":\"invalid_status\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldUpdateAdminNotes() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING);
|
||||
order.setAdminNotes("Kontaktat TS");
|
||||
|
||||
when(adminOrderWorkflowService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
|
||||
void shouldReturn400WhenStatusIsBlank() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"adminNotes\":\"Kontaktat TS\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.adminNotes").value("Kontaktat TS"));
|
||||
.content("{\"status\":\"\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception {
|
||||
void shouldReturn404WhenOrderNotFound() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123"), anyBoolean()))
|
||||
when(orderService.updateOrderStatus(eq(orderId), eq("paid")))
|
||||
.thenThrow(new OrderNotFoundException(orderId));
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingInput\":\"PN123\"}"))
|
||||
.content("{\"status\":\"paid\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenPatchingTrackingWithoutAuth() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":\"PN123456789\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
||||
void shouldReturn403WhenPatchingTrackingAsNonAdmin() throws Exception {
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":\"PN123456789\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldUpdateTrackingSuccessfully() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
|
||||
order.setTrackingId("PN123456789");
|
||||
|
||||
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":\"PN123456789\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.trackingId").value("PN123456789"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldClearTrackingWhenNull() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
|
||||
order.setTrackingId(null);
|
||||
|
||||
when(orderService.updateTracking(eq(orderId), eq(null))).thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.trackingId").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||
void shouldReturn404WhenOrderNotFoundForTracking() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
when(orderService.updateTracking(eq(orderId), eq("PN123456789")))
|
||||
.thenThrow(new OrderNotFoundException(orderId));
|
||||
|
||||
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trackingId\":\"PN123456789\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
|
|
@ -171,6 +219,7 @@ class AdminControllerTest {
|
|||
order.setPlate(plate);
|
||||
order.setLetterText("Test letter");
|
||||
order.setStatus(status);
|
||||
order.setTrackingId(null);
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
|
||||
return order;
|
||||
|
|
|
|||
|
|
@ -225,8 +225,7 @@ class AuthControllerTest {
|
|||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -264,7 +263,6 @@ class AuthControllerTest {
|
|||
mockMvc.perform(post("/api/auth/change-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
|
||||
class GuestOrderControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private OrderService orderService;
|
||||
|
||||
@MockitoBean
|
||||
private UserService userService;
|
||||
|
||||
// --- POST /api/guest-orders (create) ---
|
||||
|
||||
@Test
|
||||
void shouldCreateGuestOrderWithoutAuth() throws Exception {
|
||||
UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID guestToken = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
|
||||
Order savedOrder = new Order();
|
||||
savedOrder.setId(orderId);
|
||||
savedOrder.setPlate("ABC123");
|
||||
savedOrder.setLetterText("Hej fin bil!");
|
||||
savedOrder.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
savedOrder.setGuestEmail("guest@example.com");
|
||||
savedOrder.setGuestToken(guestToken);
|
||||
|
||||
when(orderService.createGuestOrder("ABC123", "Hej fin bil!", "guest@example.com"))
|
||||
.thenReturn(savedOrder);
|
||||
|
||||
mockMvc.perform(post("/api/guest-orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej fin bil!\",\"email\":\"guest@example.com\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.plate").value("ABC123"))
|
||||
.andExpect(jsonPath("$.letterText").value("Hej fin bil!"))
|
||||
.andExpect(jsonPath("$.status").value("pending_payment"))
|
||||
.andExpect(jsonPath("$.guestToken").value(guestToken.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidPlateFormat() throws Exception {
|
||||
mockMvc.perform(post("/api/guest-orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"INVALID\",\"letterText\":\"Hej\",\"email\":\"guest@example.com\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBlankEmail() throws Exception {
|
||||
mockMvc.perform(post("/api/guest-orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\",\"email\":\"\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidEmail() throws Exception {
|
||||
mockMvc.perform(post("/api/guest-orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\",\"email\":\"not-an-email\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBlankLetterText() throws Exception {
|
||||
mockMvc.perform(post("/api/guest-orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"\",\"email\":\"guest@example.com\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// --- GET /api/guest-orders/{token} ---
|
||||
|
||||
@Test
|
||||
void shouldGetGuestOrderByToken() throws Exception {
|
||||
UUID token = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setPlate("ABC123");
|
||||
order.setLetterText("Hej bil!");
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
order.setGuestToken(token);
|
||||
|
||||
when(orderService.getOrderByGuestToken(token)).thenReturn(order);
|
||||
|
||||
mockMvc.perform(get("/api/guest-orders/{token}", token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.plate").value("ABC123"))
|
||||
.andExpect(jsonPath("$.status").value("processing"))
|
||||
.andExpect(jsonPath("$.guestToken").value(token.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn404WhenGuestOrderTokenNotFound() throws Exception {
|
||||
UUID token = UUID.randomUUID();
|
||||
when(orderService.getOrderByGuestToken(token))
|
||||
.thenThrow(new OrderNotFoundException(token));
|
||||
|
||||
mockMvc.perform(get("/api/guest-orders/{token}", token))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
// --- POST /api/guest-orders/{token}/pay ---
|
||||
|
||||
@Test
|
||||
void shouldConfirmGuestPayment() throws Exception {
|
||||
UUID token = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setPlate("ABC123");
|
||||
order.setLetterText("Hej bil!");
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
order.setGuestToken(token);
|
||||
|
||||
when(orderService.confirmGuestPayment(token)).thenReturn(order);
|
||||
|
||||
mockMvc.perform(post("/api/guest-orders/{token}/pay", token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.status").value("processing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn409WhenPaymentAlreadyConfirmed() throws Exception {
|
||||
UUID token = UUID.randomUUID();
|
||||
when(orderService.confirmGuestPayment(token))
|
||||
.thenThrow(new InvalidOrderStateException("Beställningen kan inte ändras i detta tillstånd"));
|
||||
|
||||
mockMvc.perform(post("/api/guest-orders/{token}/pay", token))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn404WhenPayingWithUnknownToken() throws Exception {
|
||||
UUID token = UUID.randomUUID();
|
||||
when(orderService.confirmGuestPayment(token))
|
||||
.thenThrow(new OrderNotFoundException(token));
|
||||
|
||||
mockMvc.perform(post("/api/guest-orders/{token}/pay", token))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
|
@ -18,22 +18,16 @@ import org.springframework.boot.test.context.SpringBootTest;
|
|||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
|
||||
class OrderControllerTest {
|
||||
|
||||
private static final String TEST_SECRET =
|
||||
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
|
|
@ -44,10 +38,9 @@ class OrderControllerTest {
|
|||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -107,31 +100,11 @@ class OrderControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WhenPostingWithoutAuth() throws Exception {
|
||||
void shouldReturn403WhenPostingWithoutAuth() throws Exception {
|
||||
mockMvc.perform(post("/api/orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception {
|
||||
JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L);
|
||||
String expiredToken = expiredJwtService.generateToken("test@bilhej.se");
|
||||
|
||||
mockMvc.perform(get("/api/orders")
|
||||
.header("Authorization", "Bearer " + expiredToken))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception {
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value(org.hamcrest.Matchers.containsString("session")));
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -39,11 +39,10 @@ class PaymentControllerTest {
|
|||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/payment/{orderId}/pay",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AdminOrderStatusRulesTest {
|
||||
|
||||
@Test
|
||||
void shouldIncludeCurrentAndTargetsForSentOrder() {
|
||||
Order order = orderWithStatus(OrderStatus.SENT);
|
||||
|
||||
assertEquals(
|
||||
java.util.List.of("sent", "delivered", "failed"),
|
||||
AdminOrderStatusRules.allowedStatusValues(order));
|
||||
assertTrue(AdminOrderStatusRules.canRegisterShipment(order));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowOnlyFailedFromPendingPayment() {
|
||||
Order order = orderWithStatus(OrderStatus.PENDING_PAYMENT);
|
||||
|
||||
assertEquals(
|
||||
java.util.List.of("pending_payment", "failed"),
|
||||
AdminOrderStatusRules.allowedStatusValues(order));
|
||||
assertFalse(AdminOrderStatusRules.canRegisterShipment(order));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeFailedRecoveryOptionsWhenTrackingExists() {
|
||||
Order order = orderWithStatus(OrderStatus.FAILED);
|
||||
order.setTrackingId("PN123");
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
|
||||
assertTrue(AdminOrderStatusRules.allowedStatusValues(order).contains("sent"));
|
||||
assertTrue(AdminOrderStatusRules.canRegisterShipment(order));
|
||||
}
|
||||
|
||||
private static Order orderWithStatus(OrderStatus status) {
|
||||
Order order = new Order();
|
||||
order.setStatus(status);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AdminOrderWorkflowServiceTest {
|
||||
|
||||
@Mock
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Mock
|
||||
private OrderNotificationService orderNotificationService;
|
||||
|
||||
@InjectMocks
|
||||
private AdminOrderWorkflowService adminOrderWorkflowService;
|
||||
|
||||
@Test
|
||||
void shouldRegisterShipmentFromProcessingAndSetSent() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.registerShipment(orderId, "PN123456789", true);
|
||||
|
||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||
assertEquals("PN123456789", result.getTrackingId());
|
||||
assertNotNull(result.getShippedAt());
|
||||
verify(orderNotificationService).notifyOrderSent(result, "PN123456789");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRegisterShipmentWhenPendingPayment() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> adminOrderWorkflowService.registerShipment(orderId, "PN123", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidAdminStatusTransition() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> adminOrderWorkflowService.updateOrderStatus(orderId, "delivered"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkPendingPaymentAsFailed() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "failed");
|
||||
|
||||
assertEquals(OrderStatus.FAILED, result.getStatus());
|
||||
verify(orderNotificationService).notifyOrderFailed(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertFailedToPendingPaymentWhenUnpaid() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "pending_payment");
|
||||
|
||||
assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertFailedToProcessingWhenPaidWithoutTracking() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "processing");
|
||||
|
||||
assertEquals(OrderStatus.PROCESSING, result.getStatus());
|
||||
verify(orderNotificationService, never()).notifyOrderFailed(any(Order.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertFailedToSentWhenPaidWithoutTracking() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "sent");
|
||||
|
||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||
assertNotNull(result.getShippedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRegisterShipmentFromFailedWhenPaid() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
order.setAmountPaid(new BigDecimal("49.00"));
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.registerShipment(orderId, "PN888", true);
|
||||
|
||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||
assertEquals("PN888", result.getTrackingId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRevertFailedToSentWhenTrackingExists() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
order.setTrackingId("PN123");
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "sent");
|
||||
|
||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotifyCustomerOnFailedStatusFromProcessing() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
adminOrderWorkflowService.updateOrderStatus(orderId, "failed");
|
||||
|
||||
verify(orderNotificationService).notifyOrderFailed(any(Order.class));
|
||||
}
|
||||
}
|
||||
|
|
@ -27,9 +27,6 @@ class OrderServiceTest {
|
|||
@Mock
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Mock
|
||||
private OrderNotificationService orderNotificationService;
|
||||
|
||||
@InjectMocks
|
||||
private OrderService orderService;
|
||||
|
||||
|
|
@ -252,123 +249,4 @@ class OrderServiceTest {
|
|||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.confirmPayment(orderId, otherUserId));
|
||||
}
|
||||
|
||||
// --- Guest order: createGuestOrder ---
|
||||
|
||||
@Test
|
||||
void shouldCreateGuestOrderWithPendingPaymentStatus() {
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.createGuestOrder("ABC123", "Hej fin bil!", "guest@example.com");
|
||||
|
||||
assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus());
|
||||
assertEquals("ABC123", result.getPlate());
|
||||
assertEquals("Hej fin bil!", result.getLetterText());
|
||||
assertEquals("guest@example.com", result.getGuestEmail());
|
||||
assertNull(result.getUserId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizeGuestPlateToUppercaseAndTrim() {
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.createGuestOrder(" abc123 ", "Test", "guest@example.com");
|
||||
|
||||
assertEquals("ABC123", result.getPlate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizeGuestEmailToLowercaseAndTrim() {
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.createGuestOrder("ABC123", "Test", " Guest@EXAMPLE.COM ");
|
||||
|
||||
assertEquals("guest@example.com", result.getGuestEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullGuestEmail() {
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.createGuestOrder("ABC123", "Test", null);
|
||||
|
||||
assertNull(result.getGuestEmail());
|
||||
}
|
||||
|
||||
// --- Guest order: getOrderByGuestToken ---
|
||||
|
||||
@Test
|
||||
void shouldGetGuestOrderByToken() {
|
||||
UUID token = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setGuestToken(token);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
|
||||
|
||||
Order result = orderService.getOrderByGuestToken(token);
|
||||
|
||||
assertSame(order, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenGuestTokenNotFound() {
|
||||
UUID token = UUID.randomUUID();
|
||||
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.getOrderByGuestToken(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenGuestTokenPointsToUserOwnedOrder() {
|
||||
UUID token = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setGuestToken(token);
|
||||
order.setUserId(UUID.randomUUID()); // security: user-owned order must not be exposed
|
||||
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.getOrderByGuestToken(token));
|
||||
}
|
||||
|
||||
// --- Guest order: confirmGuestPayment ---
|
||||
|
||||
@Test
|
||||
void shouldConfirmGuestPaymentForPendingOrder() {
|
||||
UUID token = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setGuestToken(token);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.confirmGuestPayment(token);
|
||||
|
||||
assertEquals(OrderStatus.PROCESSING, result.getStatus());
|
||||
verify(orderNotificationService).notifyOrderProcessing(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenConfirmingGuestPaymentForNonPendingOrder() {
|
||||
UUID token = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setGuestToken(token);
|
||||
order.setStatus(OrderStatus.CANCELLED);
|
||||
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> orderService.confirmGuestPayment(token));
|
||||
verify(orderRepository, never()).save(any(Order.class));
|
||||
verify(orderNotificationService, never()).notifyOrderProcessing(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenConfirmingGuestPaymentForUnknownToken() {
|
||||
UUID token = UUID.randomUUID();
|
||||
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.confirmGuestPayment(token));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
package se.bilhalsning.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class PostNordTrackingNormalizerTest {
|
||||
|
||||
@Test
|
||||
void shouldTrimAndRemoveWhitespaceFromPlainId() {
|
||||
assertEquals("PN123456789", PostNordTrackingNormalizer.normalize(" PN 123 456 789 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExtractIdFromPostNordUrl() {
|
||||
String url = "https://www.postnord.se/verktyg/spara/?id=PN987654321&utm=foo";
|
||||
assertEquals("PN987654321", PostNordTrackingNormalizer.normalize(url));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenInputIsBlank() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> PostNordTrackingNormalizer.normalize(" "));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
# Bindless dev stack — standalone variant of docker-compose.yml.
|
||||
#
|
||||
# Why this exists as a standalone file (not an override):
|
||||
# Docker Compose merges `volumes:` by list concatenation, not by entry
|
||||
# replacement, so an override can't drop the bind mounts from the base file —
|
||||
# only append to them. A standalone file lets us redefine services with only
|
||||
# the volumes we want.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev-bindless.yml up -d --build
|
||||
#
|
||||
# Use this when the Docker daemon can't bind-mount the host repo correctly:
|
||||
# - Docker-in-Docker setups (e.g. this Hermes sandbox)
|
||||
# - rootless Docker with restricted mount paths
|
||||
# - Some CI runners
|
||||
#
|
||||
# For normal local dev, use docker-compose.yml — it bind-mounts the repo for
|
||||
# Vite HMR and gradle bootRun hot reload.
|
||||
#
|
||||
# Trade-off vs. the bind-mounted dev compose:
|
||||
# - The image is "frozen" at build time. Editing source on the host does not
|
||||
# affect the running container. Edit + rebuild + restart, or run
|
||||
# `docker compose up -d --build` after changes.
|
||||
# - All source lives inside the image (docker/backend.Dockerfile and
|
||||
# docker/frontend.Dockerfile COPY it in at build time).
|
||||
#
|
||||
# What you still get:
|
||||
# - Gradle caches in named volumes (.gradle, backend/build, gradle-cache)
|
||||
# so dependency downloads persist between `up` cycles.
|
||||
# - Postgres data persists across `down` (via the pgdata volume).
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: bilhej-postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mailpit:
|
||||
image: ghcr.io/axllent/mailpit:v1.28
|
||||
container_name: bilhej-mailpit
|
||||
ports:
|
||||
- "1025:1025"
|
||||
- "8025:8025"
|
||||
|
||||
backend:
|
||||
image: bilhej-backend-dev
|
||||
build:
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
context: .
|
||||
container_name: bilhej-backend
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: docker
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SWISH_NUMBER: ${SWISH_NUMBER}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
MAIL_HOST: mailpit
|
||||
MAIL_PORT: "1025"
|
||||
MAIL_USERNAME: ""
|
||||
MAIL_PASSWORD: ""
|
||||
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- backend-gradle-project:/app/.gradle
|
||||
- backend-build:/app/backend/build
|
||||
- gradle-cache:/root/.gradle
|
||||
|
||||
frontend:
|
||||
image: bilhej-frontend-dev
|
||||
build:
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
context: .
|
||||
container_name: bilhej-frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
gradle-cache:
|
||||
backend-gradle-project:
|
||||
backend-build:
|
||||
|
|
@ -89,8 +89,8 @@ services:
|
|||
sleep 1;
|
||||
done;
|
||||
echo 'Waiting for backend...';
|
||||
for i in \$(seq 1 120); do
|
||||
curl -s -o /dev/null http://backend:8080/api/vehicles/ZZZ999 && break;
|
||||
for i in \$(seq 1 60); do
|
||||
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
|
||||
sleep 1;
|
||||
done;
|
||||
echo 'Waiting for frontend...';
|
||||
|
|
|
|||
|
|
@ -49,9 +49,6 @@ services:
|
|||
build:
|
||||
dockerfile: docker/frontend.prod.Dockerfile
|
||||
context: .
|
||||
args:
|
||||
VITE_UMAMI_WEBSITE_ID: ${VITE_UMAMI_WEBSITE_ID:-}
|
||||
VITE_UMAMI_SCRIPT_URL: ${VITE_UMAMI_SCRIPT_URL:-https://analytics.bilhej.se/script.js}
|
||||
container_name: bilhej-frontend-prod
|
||||
ports:
|
||||
- "3001:80"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,3 @@
|
|||
FROM eclipse-temurin:21-jdk
|
||||
WORKDIR /app
|
||||
|
||||
# Copy build configuration and wrapper first so this layer caches well.
|
||||
COPY gradlew settings.gradle build.gradle ./
|
||||
COPY gradle/ gradle/
|
||||
RUN chmod +x gradlew
|
||||
|
||||
# Copy backend module. The dev compose overlays this with a host bind mount
|
||||
# for live source changes; if the bind mount is absent (DinD, CI, k8s) the
|
||||
# image is still self-contained and `gradlew :backend:bootRun` will work.
|
||||
COPY backend/ backend/
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"]
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first so this layer caches independently of source changes.
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the frontend. The dev compose overlays individual paths
|
||||
# (./frontend/src, ./frontend/public, ./frontend/index.html) with host bind
|
||||
# mounts for live reload; if those bind mounts are absent (DinD, CI, k8s)
|
||||
# the image is still self-contained and `npm run dev` will serve from the
|
||||
# COPY'd files.
|
||||
COPY frontend/ .
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG VITE_UMAMI_WEBSITE_ID=
|
||||
ARG VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||
ENV VITE_UMAMI_WEBSITE_ID=$VITE_UMAMI_WEBSITE_ID
|
||||
ENV VITE_UMAMI_SCRIPT_URL=$VITE_UMAMI_SCRIPT_URL
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
server_name bilhej.se www.bilhej.se;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/cert.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/cert.key;
|
||||
|
|
@ -11,59 +11,20 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ── Security headers ──────────────────────────────────────────
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
# Strict-Transport-Security only when HTTPS is active
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
|
||||
# ── Gzip ──────────────────────────────────────────────────────
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml
|
||||
application/xml text/javascript image/svg+xml text/markdown;
|
||||
gzip_vary on;
|
||||
gzip_min_length 256;
|
||||
gzip_comp_level 6;
|
||||
|
||||
# ── API reverse proxy ─────────────────────────────────────────
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
|
||||
# ── Static assets with far-future cache ───────────────────────
|
||||
location ~* \.(?:ico|svg|css|js|woff2?|ttf|eot|png|jpg|jpeg|gif|webp|avif)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary "Accept-Encoding";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ── Robots + sitemap (never cached, fresh every time) ─────────
|
||||
location = /robots.txt {
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
try_files $uri =404;
|
||||
}
|
||||
location = /sitemap.xml {
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ── SPA fallback — serve index.html for all other routes ──────
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# ── Deny access to dotfiles ───────────────────────────────────
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
gzip_vary on;
|
||||
gzip_min_length 256;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
# Umami analytics (BilHej app)
|
||||
|
||||
Privacy-friendly page analytics via self-hosted [Umami](https://umami.is/docs) on **`https://analytics.bilhej.se`**. Server install is live on the VPS; this doc is for **BilHej code and deploy config** only.
|
||||
|
||||
## Values (production)
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Collector | `https://analytics.bilhej.se` |
|
||||
| Tracker script | `https://analytics.bilhej.se/script.js` |
|
||||
| Dashboard | `https://analytics.bilhej.se` (admin login) |
|
||||
| Website in Umami | Name **BilHej**, domain **`bilhej.se`** |
|
||||
| Website ID | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
|
||||
|
||||
The Website ID is public in the browser (tracking snippet). Set it via **`VITE_UMAMI_WEBSITE_ID`** in production frontend build env — do not hardcode in source.
|
||||
|
||||
**Note:** Umami 3.1 on this server uses the default **`/script.js`** path. `TRACKER_SCRIPT_NAME` / custom `bilhej-stats.js` is not applied in this version.
|
||||
|
||||
### Example snippet (for reference)
|
||||
|
||||
```html
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.bilhej.se/script.js"
|
||||
data-website-id="ce59614c-9f2a-4f99-8ba3-c5217f88c3f7"
|
||||
></script>
|
||||
```
|
||||
|
||||
## Frontend env
|
||||
|
||||
Production builds read this from the **Forgejo Actions secret** `VITE_UMAMI_WEBSITE_ID`. The deploy workflow writes it into `.env` on the server, then `docker compose -f docker-compose.prod.yml build` bakes it into the frontend image.
|
||||
|
||||
**Forgejo → Repository → Settings → Actions → Secrets:**
|
||||
|
||||
| Secret | Value |
|
||||
|--------|--------|
|
||||
| `VITE_UMAMI_WEBSITE_ID` | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
|
||||
|
||||
Not a high-risk secret (the same ID appears in the browser), but keeping it in Forgejo matches other deploy config.
|
||||
|
||||
Manual deploy on the server (without Forgejo) works the same way: put the line in the project `.env` before `docker compose ... up --build`.
|
||||
|
||||
Optional override (default matches production):
|
||||
|
||||
```bash
|
||||
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||
```
|
||||
|
||||
Leave `VITE_UMAMI_WEBSITE_ID` unset in local dev unless you intentionally send traffic to production Umami.
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
- [x] Load `script.js` with `data-website-id` from `VITE_UMAMI_WEBSITE_ID` (only when set).
|
||||
- [x] Send **SPA pageviews** on Vue Router `afterEach` (`data-auto-track="false"`).
|
||||
- [x] Update **integritetspolicy** — analytics, country-level stats, no IP stored in BilHej DB.
|
||||
- [x] Admin link **Webbstatistik** → Umami dashboard (prod builds only).
|
||||
|
||||
Umami derives **country** from the visitor IP at ingest and does not show IP lists in the UI. BilHej must not store visitor IPs for analytics.
|
||||
|
||||
## Verify after deploy
|
||||
|
||||
1. Browse `https://bilhej.se` (several routes).
|
||||
2. Umami → **BilHej** → **Realtime** / **Countries**.
|
||||
|
||||
## Server layout (reference)
|
||||
|
||||
| Item | Actual on VPS |
|
||||
|------|----------------|
|
||||
| Compose project | `~/umami` (`/home/jocke/umami`) |
|
||||
| Public access | nginx → `http://umami:3000` on Docker network `web` (host port 3000 used by open-webui) |
|
||||
| Database | `umami-db` on internal network `umami-internal` only |
|
||||
|
||||
```bash
|
||||
cd ~/umami
|
||||
docker compose ps
|
||||
docker compose logs -f umami
|
||||
docker compose pull && docker compose up -d # updates — read release notes first
|
||||
docker exec umami-db pg_dump -U umami umami > ~/umami-backup-$(date +%F).sql
|
||||
```
|
||||
|
||||
Country stats require nginx to pass **`X-Forwarded-For`** (already configured for this vhost).
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
|
||||
import { clearMailpit, waitForEmailChangeToken } from './helpers/mailpit'
|
||||
import {
|
||||
clearMailpit,
|
||||
countMessagesTo,
|
||||
waitForEmailChangeToken,
|
||||
} from './helpers/mailpit'
|
||||
|
||||
test.describe('Account settings', () => {
|
||||
test('can change password and change back', async ({ page, request }) => {
|
||||
|
|
@ -46,6 +50,8 @@ test.describe('Account settings', () => {
|
|||
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
|
||||
),
|
||||
).toBeVisible()
|
||||
expect(await countMessagesTo(request, tempEmail)).toBe(1)
|
||||
expect(await countMessagesTo(request, originalEmail)).toBe(0)
|
||||
|
||||
const token = await waitForEmailChangeToken(request, tempEmail, {
|
||||
publicBaseUrl: 'http://frontend',
|
||||
|
|
@ -64,6 +70,7 @@ test.describe('Account settings', () => {
|
|||
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
|
||||
),
|
||||
).toBeVisible()
|
||||
expect(await countMessagesTo(request, originalEmail)).toBe(1)
|
||||
|
||||
const restoreToken = await waitForEmailChangeToken(request, originalEmail, {
|
||||
publicBaseUrl: 'http://frontend',
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { loginAsAdmin } from './helpers/admin'
|
||||
|
||||
const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
|
||||
const SEEDED_ORDER_SHORT_ID = SEEDED_ORDER_ID.slice(0, 8)
|
||||
const PROCESSING_PLATE = 'JKL012'
|
||||
|
||||
function rowByPlate(page: import('@playwright/test').Page, plate: string) {
|
||||
return page.locator('.admin__row').filter({
|
||||
has: page.locator('.admin__plate', { hasText: plate }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Admin dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
})
|
||||
|
||||
test('admin can navigate to admin page', async ({ page }) => {
|
||||
|
|
@ -41,7 +37,9 @@ test.describe('Admin dashboard', () => {
|
|||
await page.goto('/admin')
|
||||
|
||||
await expect(page.getByRole('columnheader', { name: 'Datum' })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: 'Beställnings-ID' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: 'E-post' })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: 'Regnr' })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
|
||||
|
|
@ -71,37 +69,34 @@ test.describe('Admin dashboard', () => {
|
|||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('click row shows tracking section', async ({ page }) => {
|
||||
test('click expand button shows tracking section', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||
|
||||
await rowByPlate(page, PROCESSING_PLATE).click()
|
||||
const expandBtns = page.locator('.admin__expand-btn')
|
||||
await expandBtns.first().click()
|
||||
|
||||
await expect(page.getByText('Registrera utskick').first()).toBeVisible()
|
||||
await expect(page.getByText('Spårnings-ID').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('click row again collapses it', async ({ page }) => {
|
||||
test('click expand button again collapses it', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||
|
||||
const row = rowByPlate(page, PROCESSING_PLATE)
|
||||
await row.click()
|
||||
const expandBtns = page.locator('.admin__expand-btn')
|
||||
await expandBtns.first().click()
|
||||
await expect(page.locator('.admin__tracking-input').first()).toBeVisible()
|
||||
|
||||
await row.click()
|
||||
await expandBtns.first().click()
|
||||
await expect(page.locator('.admin__tracking-input').first()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('status dropdown shows current status for sent orders', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('status dropdown changes update order status', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
await page.locator('#admin-order-search').fill(SEEDED_ORDER_SHORT_ID)
|
||||
|
||||
const row = page.locator('.admin__row', { hasText: SEEDED_ORDER_SHORT_ID })
|
||||
const select = row.locator('.admin__status-select')
|
||||
await expect(select).toBeVisible()
|
||||
await expect(select).toHaveValue('sent')
|
||||
const selects = page.locator('.admin__status-select')
|
||||
await selects.first().selectOption('delivered')
|
||||
|
||||
const updatedSelect = selects.first()
|
||||
await expect(updatedSelect).toHaveValue('delivered')
|
||||
})
|
||||
|
||||
test('admin cannot access admin page without auth', async ({ page }) => {
|
||||
|
|
@ -113,21 +108,20 @@ test.describe('Admin dashboard', () => {
|
|||
|
||||
test('expanded row shows tracking input and save button', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||
|
||||
await rowByPlate(page, PROCESSING_PLATE).click()
|
||||
const expandBtns = page.locator('.admin__expand-btn')
|
||||
await expandBtns.first().click()
|
||||
|
||||
await expect(page.getByText('Registrera utskick').first()).toBeVisible()
|
||||
await expect(page.getByText('Spårnings-ID').first()).toBeVisible()
|
||||
await expect(page.locator('.admin__tracking-input')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Registrera utskick' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Spara' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows PostNord link when trackingId exists', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
|
||||
await page.locator('.admin__row').last().click()
|
||||
const expandBtns = page.locator('.admin__expand-btn')
|
||||
await expandBtns.last().click()
|
||||
|
||||
const trackingLink = page.locator('.admin__tracking-link')
|
||||
await expect(trackingLink).toBeVisible()
|
||||
|
|
@ -138,7 +132,8 @@ test.describe('Admin dashboard', () => {
|
|||
await page.goto('/admin')
|
||||
|
||||
const defRow = page.locator('.admin__row', { hasText: 'DEF456' }).first()
|
||||
await defRow.click()
|
||||
const expandBtn = defRow.locator('.admin__expand-btn')
|
||||
await expandBtn.click()
|
||||
|
||||
const trackingLink = page.locator('.admin__tracking-link')
|
||||
await expect(trackingLink).not.toBeVisible()
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
|
||||
|
||||
/**
|
||||
* Admin order status and shipment flows (serial — mutates seeded orders).
|
||||
* Requires docker e2e stack with dev seeds (DEF456, JKL012, ABC123).
|
||||
*/
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const PENDING_ORDER_SHORT_ID = 'c2eebc99'
|
||||
const PROCESSING_PLATE = 'JKL012'
|
||||
const SENT_ORDER_SHORT_ID = 'c1eebc99'
|
||||
|
||||
function orderRowByPlate(page: import('@playwright/test').Page, plate: string) {
|
||||
return page.locator('.admin__row').filter({
|
||||
has: page.locator('.admin__plate', { hasText: plate }),
|
||||
})
|
||||
}
|
||||
|
||||
function orderRowByShortId(
|
||||
page: import('@playwright/test').Page,
|
||||
shortId: string,
|
||||
) {
|
||||
return page.locator('.admin__row', { hasText: shortId })
|
||||
}
|
||||
|
||||
test.describe('Admin fulfillment flows', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
await openAdminDashboard(page)
|
||||
})
|
||||
|
||||
test('can mark unpaid order as failed', async ({ page }) => {
|
||||
await page.locator('#admin-order-search').fill(PENDING_ORDER_SHORT_ID)
|
||||
const row = orderRowByShortId(page, PENDING_ORDER_SHORT_ID)
|
||||
const select = row.locator('.admin__status-select')
|
||||
await select.selectOption('failed')
|
||||
await expect(select).toHaveValue('failed')
|
||||
await expect(page.getByRole('alert')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('can revert unpaid failed order to pending payment', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('#admin-order-search').fill(PENDING_ORDER_SHORT_ID)
|
||||
const row = orderRowByShortId(page, PENDING_ORDER_SHORT_ID)
|
||||
const select = row.locator('.admin__status-select')
|
||||
await expect(select).toHaveValue('failed')
|
||||
await select.selectOption('pending_payment')
|
||||
await expect(select).toHaveValue('pending_payment')
|
||||
})
|
||||
|
||||
test('can register shipment for processing order', async ({ page }) => {
|
||||
const row = orderRowByPlate(page, PROCESSING_PLATE)
|
||||
await row.click()
|
||||
await page
|
||||
.locator('.admin__tracking-input')
|
||||
.fill('PN-E2E-FULFILLMENT-001')
|
||||
await page.getByRole('button', { name: 'Registrera utskick' }).click()
|
||||
await expect(row.locator('.admin__status-select')).toHaveValue('sent')
|
||||
await expect(page.locator('.admin__tracking-link')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can mark sent order as delivered', async ({ page }) => {
|
||||
await page.locator('#admin-order-search').fill(SENT_ORDER_SHORT_ID)
|
||||
const row = orderRowByShortId(page, SENT_ORDER_SHORT_ID)
|
||||
const select = row.locator('.admin__status-select')
|
||||
if ((await select.inputValue()) !== 'delivered') {
|
||||
await select.selectOption('delivered')
|
||||
}
|
||||
await expect(select).toHaveValue('delivered')
|
||||
})
|
||||
|
||||
test('can mark delivered order as failed then back to sent when tracking exists', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('#admin-order-search').fill(SENT_ORDER_SHORT_ID)
|
||||
const row = orderRowByShortId(page, SENT_ORDER_SHORT_ID)
|
||||
const select = row.locator('.admin__status-select')
|
||||
|
||||
await select.selectOption('failed')
|
||||
await expect(select).toHaveValue('failed')
|
||||
|
||||
await select.selectOption('sent')
|
||||
await expect(select).toHaveValue('sent')
|
||||
})
|
||||
})
|
||||
|
|
@ -70,13 +70,6 @@ test.describe('Auth guards', () => {
|
|||
})
|
||||
|
||||
test('allows admin user to access /admin', async ({ page }) => {
|
||||
await page.route('**/api/admin/orders', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '[]',
|
||||
}),
|
||||
)
|
||||
const jwt = makeJwt({ role: 'admin' })
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let plateCounter = 0
|
||||
|
||||
function uniquePlate(prefix: string): string {
|
||||
plateCounter += 1
|
||||
const digits = String(10 + (plateCounter % 90))
|
||||
const letter = String.fromCharCode(65 + (plateCounter % 26))
|
||||
return `${prefix}${digits}${letter}`
|
||||
const digits = String((Date.now() % 90) + 10)
|
||||
return `${prefix}${digits}E`
|
||||
}
|
||||
|
||||
test.describe('Deferred payment and admin lookup', () => {
|
||||
let plate = ''
|
||||
const plate = uniquePlate('LAT')
|
||||
const letterText = 'E2E-test: betalar senare från orderhistoriken.'
|
||||
|
||||
let orderId = ''
|
||||
|
|
@ -40,26 +35,9 @@ test.describe('Deferred payment and admin lookup', () => {
|
|||
await page.getByRole('button', { name: 'Ja, jag har betalat' }).click()
|
||||
}
|
||||
|
||||
async function openAdminTodoBoard(page: import('@playwright/test').Page) {
|
||||
await openAdminDashboard(page)
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
await expect(page.locator('.admin__stat--active')).toContainText('Att göra')
|
||||
}
|
||||
|
||||
async function searchAdminOrders(
|
||||
page: import('@playwright/test').Page,
|
||||
query: string,
|
||||
) {
|
||||
const search = page.locator('#admin-order-search')
|
||||
await search.click()
|
||||
await search.fill(query)
|
||||
await expect(search).toHaveValue(query)
|
||||
}
|
||||
|
||||
test('user creates order, leaves payment, and pays later from orders', async ({
|
||||
page,
|
||||
}) => {
|
||||
plate = uniquePlate('LAT')
|
||||
await loginAsTestUser(page)
|
||||
|
||||
await page.goto(`/compose?plate=${plate}`)
|
||||
|
|
@ -92,31 +70,47 @@ test.describe('Deferred payment and admin lookup', () => {
|
|||
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('admin finds paid order under Att göra by order id and plate', async ({
|
||||
test('admin finds paid order under Att göra when searching partial order id', async ({
|
||||
page,
|
||||
}) => {
|
||||
await loginAsAdmin(page)
|
||||
await openAdminTodoBoard(page)
|
||||
await page.goto('/admin')
|
||||
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
await page.locator('#admin-order-search').fill(shortOrderId)
|
||||
|
||||
await searchAdminOrders(page, shortOrderId)
|
||||
const row = page.locator('.admin__row', { hasText: shortOrderId })
|
||||
await expect(row).toBeVisible({ timeout: 15_000 })
|
||||
await expect(row).toHaveClass(/admin__row--todo/)
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId)
|
||||
const plateInAdmin = (await row.locator('.admin__plate').textContent())?.trim()
|
||||
expect(plateInAdmin).toBeTruthy()
|
||||
|
||||
await searchAdminOrders(page, orderId)
|
||||
await expect(
|
||||
page.locator('.admin__row', { hasText: shortOrderId }),
|
||||
).toBeVisible()
|
||||
|
||||
await searchAdminOrders(page, plateInAdmin!)
|
||||
const rowByPlate = page.locator('.admin__row').filter({
|
||||
has: page.locator('.admin__plate', { hasText: plateInAdmin! }),
|
||||
await expect(row.locator('.admin__plate')).toHaveText(plate)
|
||||
await expect(row).toHaveClass(/admin__row--todo/)
|
||||
})
|
||||
await expect(rowByPlate).toBeVisible()
|
||||
await expect(rowByPlate.locator('.admin__order-id')).toHaveText(shortOrderId)
|
||||
|
||||
test('admin finds paid order when searching full order id', async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
await page.goto('/admin')
|
||||
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
await page.locator('#admin-order-search').fill(orderId)
|
||||
|
||||
const row = page.locator('.admin__row', { hasText: shortOrderId })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId)
|
||||
await expect(row.locator('.admin__plate')).toHaveText(plate)
|
||||
})
|
||||
|
||||
test('admin finds paid order when searching registration number', async ({
|
||||
page,
|
||||
}) => {
|
||||
await loginAsAdmin(page)
|
||||
await page.goto('/admin')
|
||||
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
await page.locator('#admin-order-search').fill(plate)
|
||||
|
||||
const row = page.locator('.admin__row', { hasText: shortOrderId })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row.locator('.admin__plate')).toHaveText(plate)
|
||||
})
|
||||
|
||||
test('admin does not show unpaid order under Att göra before payment', async ({
|
||||
|
|
@ -136,19 +130,15 @@ test.describe('Deferred payment and admin lookup', () => {
|
|||
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
await loginAsAdmin(page)
|
||||
await openAdminTodoBoard(page)
|
||||
await page.goto('/admin')
|
||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||
|
||||
const unpaidRow = page.locator('.admin__row', { hasText: unpaidShortId })
|
||||
await expect(unpaidRow).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Väntar/ }).click()
|
||||
await expect(page.locator('.admin__stat--active')).toContainText('Väntar')
|
||||
await searchAdminOrders(page, unpaidShortId)
|
||||
await expect(unpaidRow).toBeVisible({ timeout: 15_000 })
|
||||
const plateInAdmin = (await unpaidRow.locator('.admin__plate').textContent())?.trim()
|
||||
expect(plateInAdmin).toBeTruthy()
|
||||
await searchAdminOrders(page, plateInAdmin!)
|
||||
await page.locator('#admin-order-search').fill(unpaidPlate)
|
||||
await expect(unpaidRow).toBeVisible()
|
||||
await expect(unpaidRow.locator('.admin__plate')).toHaveText(plateInAdmin!)
|
||||
await expect(unpaidRow.locator('.admin__plate')).toHaveText(unpaidPlate)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Expired token logout', () => {
|
||||
test('router guard redirects expired token to login and logs out', async ({
|
||||
page,
|
||||
}) => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past })
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).not.toBeVisible()
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||
expect(stored).toBeNull()
|
||||
})
|
||||
|
||||
test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({
|
||||
page,
|
||||
}) => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future })
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
||||
await page.goto('/orders')
|
||||
await page.waitForURL(/\/logga-in\?redirect=\/orders/)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible()
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||
expect(stored).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
const signature = 'test-sig'
|
||||
return `${header}.${body}.${signature}`
|
||||
}
|
||||
|
|
@ -100,13 +100,6 @@ test.describe('Header auth state', () => {
|
|||
})
|
||||
|
||||
test('logout redirects to home page', async ({ page }) => {
|
||||
await page.route('**/api/orders', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '[]',
|
||||
}),
|
||||
)
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
||||
await page.goto('/orders')
|
||||
await page.evaluate(
|
||||
|
|
@ -207,40 +200,6 @@ test.describe('Header auth state', () => {
|
|||
})
|
||||
})
|
||||
|
||||
test.describe('Header on mobile viewport', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
test('menu reveals navigation links when authenticated', async ({ page }) => {
|
||||
await authenticateUser(page)
|
||||
await page.goto('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Mina beställningar' }),
|
||||
).not.toBeVisible()
|
||||
|
||||
await header.getByRole('button', { name: 'Öppna meny' }).click()
|
||||
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Mina beställningar' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Byt e-postadress' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('home page has no horizontal overflow', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const scrollWidth = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth,
|
||||
)
|
||||
const clientWidth = await page.evaluate(
|
||||
() => document.documentElement.clientWidth,
|
||||
)
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
|
||||
})
|
||||
})
|
||||
|
||||
async function authenticateUser(page: import('@playwright/test').Page) {
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
||||
await page.goto('/')
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
export async function loginAsAdmin(page: Page) {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
}
|
||||
|
||||
export async function openAdminDashboard(page: Page) {
|
||||
await page.goto('/admin')
|
||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ test.describe('Order history', () => {
|
|||
|
||||
await page.goto('/orders')
|
||||
|
||||
await expect(page.getByText('Skickat').first()).toBeVisible()
|
||||
await expect(page.getByText('Skickat')).toBeVisible()
|
||||
await expect(page.getByText('Väntar på betalning').first()).toBeVisible()
|
||||
await expect(page.getByText('Levererat').first()).toBeVisible()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -49,31 +49,6 @@ test.describe('Payment redirect', () => {
|
|||
|
||||
await page.waitForURL(/\/betalning\//)
|
||||
await expect(page.getByText('Swisha till')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Jag har betalat' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows QR code for desktop scanning', async ({ page }) => {
|
||||
await page.goto('/compose?plate=QRA222')
|
||||
await page.getByLabel('Ditt meddelande').fill('Fin bil!')
|
||||
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||
|
||||
await page.waitForURL(/\/betalning\//)
|
||||
await expect(page.getByRole('img', { name: 'Swish QR-kod' })).toBeVisible()
|
||||
await expect(page.getByText('Skanna QR-koden')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows Swish payment link with pre-filled data', async ({ page }) => {
|
||||
await page.goto('/compose?plate=MNO345')
|
||||
await page.getByLabel('Ditt meddelande').fill('Hej där!')
|
||||
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||
|
||||
await page.waitForURL(/\/betalning\//)
|
||||
const swishLink = page.getByRole('link', { name: 'Betala med Swish' })
|
||||
await expect(swishLink).toBeVisible()
|
||||
const href = await swishLink.getAttribute('href')
|
||||
expect(href).toContain('app.swish.nu')
|
||||
expect(href).toContain('amt=49.00')
|
||||
await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,143 +4,12 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Bilhej — Skicka brev till fordonsägare via registreringsnummer</title>
|
||||
<meta name="title" content="Bilhej — Skicka brev till fordonsägare via registreringsnummer" />
|
||||
<meta name="description" content="Skicka ett fysiskt brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, betala 49 kr via Swish. Vi postar brevet åt dig med spårning." />
|
||||
<meta name="keywords" content="brev till bilägare, kontakta fordonsägare, registreringsnummer, skicka brev, anonymt meddelande, parkeringsskada, köp bilen, tuta, körbeteende, svensk post" />
|
||||
<meta name="author" content="Bilhej" />
|
||||
<meta name="robots" content="index, follow, max-image-preview:large" />
|
||||
<meta name="language" content="Swedish" />
|
||||
<meta name="description" content="Skicka ett brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, så postar vi det." />
|
||||
<meta name="theme-color" content="#1d4ed8" />
|
||||
<link rel="canonical" href="https://bilhej.se/" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://bilhej.se/" />
|
||||
<meta property="og:title" content="Bilhej — Skicka brev till fordonsägare" />
|
||||
<meta property="og:description" content="Skicka ett fysiskt brev till en bilägare via registreringsnummer. Skriv, betala 49 kr, vi postar." />
|
||||
<meta property="og:site_name" content="Bilhej" />
|
||||
<meta property="og:locale" content="sv_SE" />
|
||||
<meta property="og:image" content="https://bilhej.se/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://bilhej.se/" />
|
||||
<meta name="twitter:title" content="Bilhej — Skicka brev till fordonsägare" />
|
||||
<meta name="twitter:description" content="Skicka ett fysiskt brev till en bilägare via registreringsnummer. Skriv, betala 49 kr, vi postar." />
|
||||
<meta name="twitter:image" content="https://bilhej.se/og-image.png" />
|
||||
|
||||
<!-- Performance: preconnect to font + analytics origins -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="preconnect" href="https://analytics.bilhej.se" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Structured Data: Organization -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "Bilhej",
|
||||
"url": "https://bilhej.se",
|
||||
"logo": "https://bilhej.se/favicon.svg",
|
||||
"description": "Tjänst för att skicka fysiska brev till fordonsägare via registreringsnummer.",
|
||||
"email": "kontakt@bilhej.se",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"email": "support@bilhej.se",
|
||||
"contactType": "customer support",
|
||||
"availableLanguage": ["Swedish"]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data: WebSite with SearchAction -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Bilhej",
|
||||
"url": "https://bilhej.se",
|
||||
"inLanguage": "sv-SE",
|
||||
"potentialAction": {
|
||||
"@type": "ReadAction",
|
||||
"target": "https://bilhej.se"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data: Service / Product -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
"name": "Bilhej — Brev till fordonsägare",
|
||||
"description": "Skicka ett fysiskt brev till en fordonsägare genom att ange registreringsnummer. Swish-betalning, spårning och anonym avsändare.",
|
||||
"url": "https://bilhej.se",
|
||||
"provider": {
|
||||
"@type": "Organization",
|
||||
"name": "Bilhej",
|
||||
"url": "https://bilhej.se"
|
||||
},
|
||||
"areaServed": {
|
||||
"@type": "Country",
|
||||
"name": "Sverige"
|
||||
},
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "49",
|
||||
"priceCurrency": "SEK",
|
||||
"description": "Per brev: utskrift, kuvertering och postning"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- FAQ Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Hur skickar jag ett brev till en bilägare?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Ange bilens registreringsnummer på bilhej.se, skriv ditt meddelande med en mall eller fritext, betala 49 kr via Swish, så postar vi brevet åt dig."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Behöver jag veta vem som äger bilen?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Nej, du behöver bara registreringsnumret. Vi kopplar brevet till rätt mottagare. Du ser aldrig mottagarens namn eller adress."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Kan jag vara anonym?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Ja, du kan nå bilägaren anonymt. Lägg bara till dina kontaktuppgifter i brevet om du vill att de ska kunna svara."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Vad kostar det?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "49 kr per brev. Detta täcker utskrift, kuvertering och porto. Du betalar via Swish."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<title>Bilhej — Skicka brev till fordonsägare</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
356
frontend/package-lock.json
generated
356
frontend/package-lock.json
generated
|
|
@ -9,7 +9,6 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
|
|
@ -17,7 +16,6 @@
|
|||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
|
|
@ -793,6 +791,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -810,6 +811,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -827,6 +831,9 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -844,6 +851,9 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -861,6 +871,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -878,6 +891,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1038,16 +1054,6 @@
|
|||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
|
||||
|
|
@ -1956,15 +1962,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
|
|
@ -1990,91 +1987,11 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
|
|
@ -2087,6 +2004,7 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
|
|
@ -2218,15 +2136,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
|
|
@ -2251,12 +2160,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
|
|
@ -2815,15 +2718,6 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
|
|
@ -2969,6 +2863,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -3390,6 +3285,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3411,6 +3309,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3432,6 +3333,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3453,6 +3357,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3836,15 +3743,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
|
|
@ -3889,6 +3787,7 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -4037,15 +3936,6 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||
|
|
@ -4144,23 +4034,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
|
|
@ -4211,15 +4084,6 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
@ -4230,12 +4094,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
|
|
@ -4350,12 +4208,6 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
|
@ -5242,12 +5094,6 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
|
@ -5390,12 +5236,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
|
|
@ -5411,134 +5251,6 @@
|
|||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
|
|
@ -25,7 +24,6 @@
|
|||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
|
|
|
|||
|
|
@ -5,14 +5,7 @@ const isCI = !!process.env.PLAYWRIGHT_BASE_URL
|
|||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
// CI flakes: the E2E stack runs 4 parallel Playwright workers against a
|
||||
// single backend (Spring Boot, no -Xmx cap). Under load an occasional
|
||||
// order-creation request transiently fails, which surfaces as a spurious
|
||||
// "navigation to /betalning/ timed out" failure unrelated to the code under
|
||||
// test (e.g. run 103, where 2 navigation tests failed while 94/94 passed
|
||||
// locally on the same commit). Per Playwright's guidance, retry transient
|
||||
// failures in CI; keep retries off locally for fast feedback.
|
||||
retries: isCI ? 2 : 0,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||
headless: true,
|
||||
|
|
@ -30,27 +23,6 @@ export default defineConfig({
|
|||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
testIgnore: [
|
||||
'**/deferred-payment-admin.spec.ts',
|
||||
'**/admin-fulfillment.spec.ts',
|
||||
'**/admin-dashboard.spec.ts',
|
||||
'**/account-settings.spec.ts',
|
||||
'**/password-reset.spec.ts',
|
||||
],
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'chromium-serial',
|
||||
dependencies: ['chromium'],
|
||||
testMatch: [
|
||||
'**/admin-fulfillment.spec.ts',
|
||||
'**/deferred-payment-admin.spec.ts',
|
||||
'**/admin-dashboard.spec.ts',
|
||||
'**/account-settings.spec.ts',
|
||||
'**/password-reset.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
# Allow all crawlers
|
||||
User-agent: *
|
||||
Crawl-delay: 10
|
||||
Disallow: /api/
|
||||
Disallow: /admin/
|
||||
Disallow: /orders/
|
||||
Disallow: /bestallning/*/redigera
|
||||
Disallow: /betalning/*
|
||||
Disallow: /gast-bestallning*
|
||||
Disallow: /gast-betalning/*
|
||||
Disallow: /gast-order/*
|
||||
Disallow: /andra-losenord
|
||||
Disallow: /andra-epost
|
||||
Disallow: /bekrafta-epost
|
||||
Disallow: /aterstall-losenord
|
||||
Disallow: /glomt-losenord
|
||||
|
||||
# Allow important pages
|
||||
Allow: /
|
||||
Allow: /om-oss
|
||||
Allow: /om
|
||||
Allow: /kontakt
|
||||
Allow: /integritetspolicy
|
||||
Allow: /villkor
|
||||
|
||||
# Sitemap
|
||||
Sitemap: https://bilhej.se/sitemap.xml
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://bilhej.se/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://bilhej.se/om-oss</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://bilhej.se/om</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://bilhej.se/kontakt</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://bilhej.se/integritetspolicy</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://bilhej.se/villkor</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
|
@ -1,29 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { RouterView } from 'vue-router'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
import { useSeo } from '@/composables/useSeo'
|
||||
import { routeSeo } from '@/data/routeSeo'
|
||||
|
||||
const route = useRoute()
|
||||
const seo = useSeo()
|
||||
|
||||
// Update title and meta tags on every route change
|
||||
watch(
|
||||
() => route.name,
|
||||
(name) => {
|
||||
const meta = routeSeo[name as string]
|
||||
if (meta) {
|
||||
seo.set(meta)
|
||||
} else {
|
||||
// Fallback for unnamed or dynamic routes (payment, guest, etc.)
|
||||
seo.reset()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -43,11 +43,7 @@ const mockOrders = [
|
|||
status: 'sent',
|
||||
trackingId: 'PN123456789',
|
||||
amountPaid: 49.0,
|
||||
shippedAt: '2026-05-13T12:00:00Z',
|
||||
adminNotes: null,
|
||||
createdAt: '2026-05-11T12:00:00Z',
|
||||
allowedStatuses: ['sent', 'delivered', 'failed'],
|
||||
canRegisterShipment: true,
|
||||
},
|
||||
{
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
|
|
@ -57,11 +53,7 @@ const mockOrders = [
|
|||
status: 'processing',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
shippedAt: null,
|
||||
adminNotes: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
allowedStatuses: ['processing', 'failed'],
|
||||
canRegisterShipment: true,
|
||||
},
|
||||
{
|
||||
id: 'c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13',
|
||||
|
|
@ -71,24 +63,16 @@ const mockOrders = [
|
|||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
shippedAt: null,
|
||||
adminNotes: null,
|
||||
createdAt: '2026-05-15T14:00:00Z',
|
||||
allowedStatuses: ['pending_payment', 'failed'],
|
||||
canRegisterShipment: false,
|
||||
},
|
||||
]
|
||||
|
||||
function freshMockOrders() {
|
||||
return mockOrders.map((order) => ({ ...order }))
|
||||
}
|
||||
|
||||
describe('AdminDashboard', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, freshMockOrders()),
|
||||
mockFetchResponse(200, mockOrders),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -117,10 +101,10 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Datum')
|
||||
expect(wrapper.text()).toContain('ID')
|
||||
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||
expect(wrapper.text()).toContain('E-post')
|
||||
expect(wrapper.text()).toContain('Regnr')
|
||||
expect(wrapper.text()).toContain('Brev')
|
||||
expect(wrapper.text()).toContain('Meddelande')
|
||||
expect(wrapper.text()).toContain('Status')
|
||||
})
|
||||
|
||||
|
|
@ -179,7 +163,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
await expandBtns[0].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.find('.admin__expanded-row').exists()).toBe(true)
|
||||
|
|
@ -193,7 +177,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
await expandBtns[0].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.findAll('.admin__expanded-row')).toHaveLength(1)
|
||||
|
|
@ -214,16 +198,15 @@ describe('AdminDashboard', () => {
|
|||
|
||||
it('fires status update API on dropdown change', async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
|
||||
.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, { ...mockOrders[0], status: 'delivered' }),
|
||||
mockFetchResponse(200, { ...mockOrders[0], status: 'paid' }),
|
||||
)
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const selects = wrapper.findAll('.admin__status-select')
|
||||
await selects[0].setValue('delivered')
|
||||
await selects[0].trigger('change')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
|
|
@ -231,14 +214,14 @@ describe('AdminDashboard', () => {
|
|||
'/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: '{"status":"delivered"}',
|
||||
body: '{"status":"sent"}',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows status error on failed update', async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
|
||||
.mockResolvedValueOnce(
|
||||
mockFetchResponse(500, { message: 'Server error' }),
|
||||
)
|
||||
|
|
@ -247,11 +230,10 @@ describe('AdminDashboard', () => {
|
|||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const selects = wrapper.findAll('.admin__status-select')
|
||||
await selects[0].setValue('delivered')
|
||||
await selects[0].trigger('change')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(wrapper.text()).toContain('Server error')
|
||||
expect(wrapper.text()).toContain('Kunde inte uppdatera status')
|
||||
})
|
||||
|
||||
it('formats dates in Swedish locale', async () => {
|
||||
|
|
@ -265,7 +247,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
await expandBtns[0].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
|
|
@ -278,7 +260,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
await expandBtns[0].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
|
|
@ -292,7 +274,7 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
await expandBtns[1].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
|
|
@ -300,47 +282,32 @@ describe('AdminDashboard', () => {
|
|||
expect(link.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('fires register-shipment API on register button click', async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
|
||||
.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, {
|
||||
...mockOrders[1],
|
||||
status: 'sent',
|
||||
trackingId: 'PN999',
|
||||
}),
|
||||
it('fires PATCH on tracking save button click', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
mockFetchResponse(200, mockOrders),
|
||||
)
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
await expandBtns[1].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
await wrapper.find('.admin__tracking-input').setValue('PN999')
|
||||
const registerBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text() === 'Registrera utskick')
|
||||
expect(registerBtn).toBeDefined()
|
||||
await registerBtn!.trigger('click')
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/register-shipment',
|
||||
'/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
trackingInput: 'PN999',
|
||||
notifyCustomer: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows tracking error on failed save', async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
|
||||
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
|
||||
.mockResolvedValueOnce(
|
||||
mockFetchResponse(500, { message: 'Server error' }),
|
||||
)
|
||||
|
|
@ -348,18 +315,14 @@ describe('AdminDashboard', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const expandBtns = wrapper.findAll('.admin__row')
|
||||
const expandBtns = wrapper.findAll('.admin__expand-btn')
|
||||
await expandBtns[1].trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
await wrapper.find('.admin__tracking-input').setValue('PN999')
|
||||
const registerBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text() === 'Registrera utskick')
|
||||
await registerBtn!.trigger('click')
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(wrapper.text()).toContain('Kunde inte registrera utskick')
|
||||
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID')
|
||||
})
|
||||
|
||||
it('shows Att göra stat for processing orders', async () => {
|
||||
|
|
@ -429,10 +392,7 @@ describe('AdminDashboard', () => {
|
|||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const rows = wrapper.findAll('.admin__row')
|
||||
const processingRow = rows.find(
|
||||
(row) =>
|
||||
row.text().includes('XYZ789') && row.classes().includes('admin__row'),
|
||||
)
|
||||
const processingRow = rows.find((row) => row.text().includes('XYZ789'))
|
||||
expect(processingRow?.classes()).toContain('admin__row--todo')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ describe('AppHeader', () => {
|
|||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
expect(wrapper.find('.app-header__logout').exists()).toBe(false)
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show user email', () => {
|
||||
|
|
@ -178,7 +178,7 @@ describe('AppHeader', () => {
|
|||
|
||||
it('shows settings menu with account links', async () => {
|
||||
const { wrapper } = mountAuthenticated()
|
||||
expect(wrapper.findAll('.app-header__settings-item')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('Byt lösenord')
|
||||
|
||||
await wrapper.find('.app-header__settings-trigger').trigger('click')
|
||||
|
||||
|
|
@ -190,26 +190,15 @@ describe('AppHeader', () => {
|
|||
expect(links[1].text()).toBe('Byt lösenord')
|
||||
})
|
||||
|
||||
it('toggles mobile menu open state when menu button is clicked', async () => {
|
||||
const { wrapper } = mountAuthenticated()
|
||||
|
||||
await wrapper.find('.app-header__menu-toggle').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.classes()).toContain('app-header--menu-open')
|
||||
expect(document.body.classList.contains('nav-menu-open')).toBe(true)
|
||||
expect(wrapper.text()).toContain('Byt e-postadress')
|
||||
})
|
||||
|
||||
it('highlights settings trigger on change password page', async () => {
|
||||
const { wrapper, router } = mountAuthenticated()
|
||||
await router.push('/andra-losenord')
|
||||
await router.isReady()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
|
||||
'app-header__settings-trigger--active',
|
||||
)
|
||||
expect(
|
||||
wrapper.find('.app-header__settings-trigger').classes(),
|
||||
).toContain('app-header__settings-trigger--active')
|
||||
})
|
||||
|
||||
it('highlights settings trigger on change email page', async () => {
|
||||
|
|
@ -218,9 +207,9 @@ describe('AppHeader', () => {
|
|||
await router.isReady()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain(
|
||||
'app-header__settings-trigger--active',
|
||||
)
|
||||
expect(
|
||||
wrapper.find('.app-header__settings-trigger').classes(),
|
||||
).toContain('app-header__settings-trigger--active')
|
||||
})
|
||||
|
||||
it('does not highlight settings trigger on other pages', async () => {
|
||||
|
|
|
|||
|
|
@ -15,10 +15,7 @@ describe('ChangeEmailPage', () => {
|
|||
it('renders current email and form fields', () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
localStorage.setItem(
|
||||
'auth_token',
|
||||
makeJwt({ sub: 'test@bilhej.se', role: 'user' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', makeJwt({ sub: 'test@bilhej.se', role: 'user' }))
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
|
|
@ -38,10 +35,7 @@ describe('ChangeEmailPage', () => {
|
|||
it('shows auth email from store', () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
localStorage.setItem(
|
||||
'auth_token',
|
||||
makeJwt({ sub: 'user@example.com', role: 'user' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', makeJwt({ sub: 'user@example.com', role: 'user' }))
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ describe('ContactPage', () => {
|
|||
const link = wrapper.find('a[href="mailto:support@bilhej.se"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.text()).toBe('support@bilhej.se')
|
||||
expect(link.attributes('aria-label')).toBe(
|
||||
'Skicka till support: support@bilhej.se',
|
||||
)
|
||||
expect(link.attributes('aria-label')).toBe('Skicka till support: support@bilhej.se')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mockCreateGuestOrder: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/guestOrders', () => ({
|
||||
createGuestOrder: mocks.mockCreateGuestOrder,
|
||||
}))
|
||||
|
||||
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
|
||||
import { createGuestOrder } from '@/api/guestOrders'
|
||||
const mockCreateGuestOrder = vi.mocked(createGuestOrder)
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{
|
||||
path: '/gast-kassa',
|
||||
name: 'guest-checkout',
|
||||
component: GuestCheckoutPage,
|
||||
},
|
||||
{
|
||||
path: '/gast-betalning/:orderId',
|
||||
name: 'guest-payment',
|
||||
component: { template: '<div>Payment</div>' },
|
||||
},
|
||||
{
|
||||
path: '/logga-in',
|
||||
name: 'login',
|
||||
component: { template: '<div>Login</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountPage() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const router = createTestRouter()
|
||||
await router.push({ name: 'guest-checkout' })
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(GuestCheckoutPage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
})
|
||||
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('GuestCheckoutPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders heading and price', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
expect(wrapper.text()).toContain('Skicka ett brev')
|
||||
expect(wrapper.text()).toContain('49 kr')
|
||||
expect(wrapper.text()).toContain('Inget konto behövs')
|
||||
})
|
||||
|
||||
it('disables submit button when fields are empty', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const submit = wrapper.find('.guest-checkout__submit')
|
||||
expect(submit.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables submit button when plate, letter, and email are valid', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const inputs = wrapper.findAll('input')
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
await inputs[0].setValue('ABC123')
|
||||
await textarea.setValue('Hej, din bil står fel!')
|
||||
await inputs[1].setValue('test@example.se')
|
||||
|
||||
const submit = wrapper.find('.guest-checkout__submit')
|
||||
expect(submit.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps submit disabled with an invalid plate', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const inputs = wrapper.findAll('input')
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
await inputs[0].setValue('ABC') // too short
|
||||
await textarea.setValue('Hej')
|
||||
await inputs[1].setValue('test@example.se')
|
||||
|
||||
const submit = wrapper.find('.guest-checkout__submit')
|
||||
expect(submit.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps submit disabled with an invalid email', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const inputs = wrapper.findAll('input')
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
await inputs[0].setValue('ABC123')
|
||||
await textarea.setValue('Hej')
|
||||
await inputs[1].setValue('not-an-email')
|
||||
|
||||
const submit = wrapper.find('.guest-checkout__submit')
|
||||
expect(submit.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows character count that updates with input', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
await textarea.setValue('Hej')
|
||||
|
||||
expect(wrapper.text()).toContain('3 / 1000 tecken')
|
||||
})
|
||||
|
||||
it('shows link to login page', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
expect(wrapper.text()).toContain('Har du redan ett konto?')
|
||||
expect(wrapper.text()).toContain('Logga in')
|
||||
})
|
||||
|
||||
it('calls createGuestOrder and navigates on submit', async () => {
|
||||
mockCreateGuestOrder.mockResolvedValue({
|
||||
id: 'order-123',
|
||||
plate: 'ABC123',
|
||||
letterText: 'Hej',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
guestToken: 'token-abc',
|
||||
})
|
||||
|
||||
const { wrapper, router } = await mountPage()
|
||||
const inputs = wrapper.findAll('input')
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
await inputs[0].setValue('ABC123')
|
||||
await textarea.setValue('Hej, din bil står fel!')
|
||||
await inputs[1].setValue('test@example.se')
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateGuestOrder).toHaveBeenCalledWith(
|
||||
'ABC123',
|
||||
'Hej, din bil står fel!',
|
||||
'test@example.se',
|
||||
)
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(router.currentRoute.value.name).toBe('guest-payment')
|
||||
expect(router.currentRoute.value.params.orderId).toBe('order-123')
|
||||
expect(router.currentRoute.value.query.token).toBe('token-abc')
|
||||
expect(router.currentRoute.value.query.plate).toBe('ABC123')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message when order creation fails', async () => {
|
||||
mockCreateGuestOrder.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
const inputs = wrapper.findAll('input')
|
||||
const textarea = wrapper.find('textarea')
|
||||
|
||||
await inputs[0].setValue('ABC123')
|
||||
await textarea.setValue('Hej')
|
||||
await inputs[1].setValue('test@example.se')
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Kunde inte skapa beställningen. Försök igen senare.',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mockFetchGuestOrder: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/guestOrders', () => ({
|
||||
fetchGuestOrder: mocks.mockFetchGuestOrder,
|
||||
}))
|
||||
|
||||
import GuestOrderPage from '@/pages/GuestOrderPage.vue'
|
||||
|
||||
const mockOrder = {
|
||||
id: 'order-123',
|
||||
plate: 'ABC123',
|
||||
letterText: 'Hej, din bil står fel!',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
createdAt: '2025-01-01T12:00:00Z',
|
||||
guestToken: 'token-abc',
|
||||
}
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{
|
||||
path: '/gast-order/:token',
|
||||
name: 'guest-order',
|
||||
component: GuestOrderPage,
|
||||
},
|
||||
{
|
||||
path: '/gast-betalning/:orderId',
|
||||
name: 'guest-payment',
|
||||
component: { template: '<div>Payment</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountPage(token = 'token-abc') {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const router = createTestRouter()
|
||||
await router.push({ name: 'guest-order', params: { token } })
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(GuestOrderPage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
})
|
||||
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('GuestOrderPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
|
||||
})
|
||||
|
||||
it('shows loading state initially', async () => {
|
||||
// Never resolve so we can see the loading state
|
||||
mocks.mockFetchGuestOrder.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
expect(wrapper.text()).toContain('Laddar')
|
||||
})
|
||||
|
||||
it('renders order details after loading', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Din beställning')
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('ABC123')
|
||||
expect(wrapper.text()).toContain('order-123')
|
||||
expect(wrapper.text()).toContain('Hej, din bil står fel!')
|
||||
})
|
||||
|
||||
it('displays human-readable status labels', async () => {
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'pending_payment',
|
||||
})
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Väntar på betalning')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Behandlas" for paid status', async () => {
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'paid',
|
||||
})
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Behandlas')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Skickat" for sent status', async () => {
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'sent',
|
||||
})
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Skickat')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Levererat" for delivered status', async () => {
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'delivered',
|
||||
})
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Levererat')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Misslyckades" for failed status', async () => {
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'failed',
|
||||
})
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Misslyckades')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Avbrutet" for cancelled status', async () => {
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'cancelled',
|
||||
})
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Avbrutet')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows payment link when status is pending_payment', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Gå till betalningssidan')
|
||||
})
|
||||
})
|
||||
|
||||
it('hides payment link when order is not pending_payment', async () => {
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'sent',
|
||||
})
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).not.toContain('Gå till betalningssidan')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mocks.mockFetchGuestOrder.mockRejectedValue(new Error('Not found'))
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Kunde inte hitta beställningen. Kontrollera länken.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fetch order when token is missing in params', async () => {
|
||||
// The component guards against an empty token in onMounted.
|
||||
// Vue Router requires the :token param so we simulate by checking
|
||||
// that fetchGuestOrder is only called once per mount with a token.
|
||||
const { wrapper } = await mountPage('token-abc')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Din beställning')
|
||||
})
|
||||
expect(mocks.mockFetchGuestOrder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mockFetchGuestOrder: vi.fn(),
|
||||
mockPayGuestOrder: vi.fn(),
|
||||
mockFetchSwishInfo: vi.fn(),
|
||||
mockBuildSwishPaymentUrl: vi.fn(),
|
||||
mockToDataURL: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/guestOrders', () => ({
|
||||
fetchGuestOrder: mocks.mockFetchGuestOrder,
|
||||
payGuestOrder: mocks.mockPayGuestOrder,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/payment', () => ({
|
||||
fetchSwishInfo: mocks.mockFetchSwishInfo,
|
||||
buildSwishPaymentUrl: mocks.mockBuildSwishPaymentUrl,
|
||||
}))
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: mocks.mockToDataURL,
|
||||
},
|
||||
}))
|
||||
|
||||
import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const mockToDataURL = vi.mocked(QRCode.toDataURL)
|
||||
|
||||
const mockOrder = {
|
||||
id: 'order-123',
|
||||
plate: 'ABC123',
|
||||
letterText: 'Hej',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
guestToken: 'token-abc',
|
||||
}
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{
|
||||
path: '/gast-betalning/:orderId',
|
||||
name: 'guest-payment',
|
||||
component: GuestPaymentRedirect,
|
||||
},
|
||||
{
|
||||
path: '/gast-order/:token',
|
||||
name: 'guest-order',
|
||||
component: { template: '<div>Order</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountPage(
|
||||
orderId = 'order-123',
|
||||
token = 'token-abc',
|
||||
plate = 'ABC123',
|
||||
) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const router = createTestRouter()
|
||||
await router.push({
|
||||
name: 'guest-payment',
|
||||
params: { orderId },
|
||||
query: { token, plate },
|
||||
})
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(GuestPaymentRedirect, {
|
||||
global: { plugins: [router, pinia] },
|
||||
})
|
||||
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
function setupDefaultMocks() {
|
||||
mocks.mockFetchSwishInfo.mockResolvedValue({
|
||||
number: '0701234567',
|
||||
amount: 49,
|
||||
})
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
|
||||
mocks.mockBuildSwishPaymentUrl.mockReturnValue(
|
||||
'https://app.swish.nu/1/p/sw/?sw=46701234567&amt=49.00&msg=order-123',
|
||||
)
|
||||
mocks.mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
|
||||
}
|
||||
|
||||
describe('GuestPaymentRedirect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('renders heading and plate', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Betalning')
|
||||
})
|
||||
expect(wrapper.text()).toContain('ABC123')
|
||||
})
|
||||
|
||||
it('displays order id', async () => {
|
||||
const { wrapper } = await mountPage('order-456')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('order-456')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the amount to pay', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('49 kr')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders QR code after loading swish info', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
|
||||
})
|
||||
expect(mockToDataURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders Swish payment link', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
const link = wrapper.find('.payment__swish-link')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.attributes('href')).toContain('app.swish.nu')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays Swish number from API', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('0701234567')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the "Jag har betalat" button', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Jag har betalat')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog when clicking "Jag har betalat"', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
|
||||
expect(wrapper.text()).toContain('0701234567')
|
||||
})
|
||||
})
|
||||
|
||||
it('can cancel the confirmation dialog', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__confirm-cancel').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__confirm-cancel').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Jag har betalat')
|
||||
expect(wrapper.text()).not.toContain('Jag bekräftar')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls payGuestOrder on confirmation', async () => {
|
||||
mocks.mockPayGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'processing',
|
||||
})
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
|
||||
expect(mocks.mockPayGuestOrder).toHaveBeenCalledWith('token-abc')
|
||||
})
|
||||
|
||||
it('navigates to guest-order after successful payment', async () => {
|
||||
mocks.mockPayGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'processing',
|
||||
})
|
||||
|
||||
const { wrapper, router } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(router.currentRoute.value.name).toBe('guest-order')
|
||||
expect(router.currentRoute.value.params.token).toBe('token-abc')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when payment confirmation fails', async () => {
|
||||
mocks.mockPayGuestOrder.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Kunde inte bekräfta betalningen. Försök igen.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when swish info fetch fails', async () => {
|
||||
mocks.mockFetchSwishInfo.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Kunde inte ladda betalningsinformation. Försök igen senare.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when token is missing', async () => {
|
||||
const { wrapper } = await mountPage('order-123', '', 'ABC123')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Saknar order-token. Gå tillbaka och försök igen.',
|
||||
)
|
||||
})
|
||||
expect(mocks.mockFetchSwishInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects to guest-order when order is already paid', async () => {
|
||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
||||
...mockOrder,
|
||||
status: 'processing',
|
||||
})
|
||||
|
||||
const { router } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(router.currentRoute.value.name).toBe('guest-order')
|
||||
expect(router.currentRoute.value.params.token).toBe('token-abc')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the magic order link', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Din orderlänk')
|
||||
})
|
||||
expect(wrapper.find('.guest-payment__magic-url').text()).toContain(
|
||||
'token-abc',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import HomePage from '@/pages/HomePage.vue'
|
||||
import ComposePage from '@/pages/ComposePage.vue'
|
||||
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
|
||||
|
||||
vi.mock('@/api/vehicles', () => ({
|
||||
lookupVehicle: vi.fn(),
|
||||
|
|
@ -19,11 +17,6 @@ function createTestRouter() {
|
|||
routes: [
|
||||
{ path: '/', name: 'home', component: HomePage },
|
||||
{ path: '/compose', name: 'compose', component: ComposePage },
|
||||
{
|
||||
path: '/gast-bestallning',
|
||||
name: 'guest-checkout',
|
||||
component: GuestCheckoutPage,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
|
@ -35,12 +28,6 @@ function mountHome(router: ReturnType<typeof createTestRouter>) {
|
|||
}
|
||||
|
||||
describe('HomePage', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.removeItem('auth_token')
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders headline', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mountHome(router)
|
||||
|
|
@ -101,7 +88,7 @@ describe('HomePage', () => {
|
|||
expect(cta.text()).toBe('Fortsätt till brevet')
|
||||
})
|
||||
|
||||
it('CTA links to guest checkout with plate query param when unauthenticated', async () => {
|
||||
it('CTA links to compose page with plate query param', async () => {
|
||||
mockLookupVehicle.mockResolvedValue({
|
||||
make: 'Volvo',
|
||||
model: 'V70',
|
||||
|
|
@ -120,6 +107,6 @@ describe('HomePage', () => {
|
|||
|
||||
const cta = wrapper.find('.btn--primary')
|
||||
const href = cta.attributes('href')
|
||||
expect(href).toBe('/gast-bestallning?plate=ABC123')
|
||||
expect(href).toBe('/compose?plate=ABC123')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,26 +4,6 @@ import { createRouter, createMemoryHistory } from 'vue-router'
|
|||
import { createPinia } from 'pinia'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
|
||||
const sessionMocks = vi.hoisted(() => {
|
||||
const mockLogout = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
return {
|
||||
mockLogout,
|
||||
mockPush,
|
||||
mockAuth: { isAuthenticated: true, logout: mockLogout },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => sessionMocks.mockAuth,
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { fullPath: '/orders' } },
|
||||
push: sessionMocks.mockPush,
|
||||
},
|
||||
}))
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
|
|
@ -396,34 +376,3 @@ describe('OrdersPage', () => {
|
|||
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrdersPage — expired session (401)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
sessionMocks.mockLogout.mockClear()
|
||||
sessionMocks.mockPush.mockClear()
|
||||
sessionMocks.mockAuth.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('does not show generic error and triggers global logout/redirect on 401', async () => {
|
||||
vi.mocked(globalThis.fetch).mockImplementation((url) => {
|
||||
const urlStr = String(url)
|
||||
if (urlStr.includes('/payment/swish-info')) {
|
||||
return mockFetchResponse(200, { number: '123', amount: 49 })
|
||||
}
|
||||
return mockFetchResponse(401, { message: 'Din session har löpt ut.' })
|
||||
})
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(wrapper.text()).not.toContain('Kunde inte hämta beställningar')
|
||||
expect(sessionMocks.mockLogout).toHaveBeenCalledTimes(1)
|
||||
expect(sessionMocks.mockPush).toHaveBeenCalledWith({
|
||||
name: 'login',
|
||||
query: { redirect: '/orders' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,26 +5,14 @@ import { createRouter, createMemoryHistory } from 'vue-router'
|
|||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,mock-qr'),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/payment', () => ({
|
||||
payOrder: vi.fn(),
|
||||
fetchSwishInfo: vi.fn(),
|
||||
buildSwishPaymentUrl: vi.fn(
|
||||
(number: string, amount: number, message: string) =>
|
||||
`https://app.swish.nu/1/p/sw/?sw=${number}&amt=${amount.toFixed(2)}&msg=${message}`,
|
||||
),
|
||||
}))
|
||||
|
||||
import { payOrder, fetchSwishInfo } from '@/api/payment'
|
||||
import QRCode from 'qrcode'
|
||||
const mockPayOrder = vi.mocked(payOrder)
|
||||
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
|
||||
const mockToDataURL = vi.mocked(QRCode.toDataURL)
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
|
|
@ -71,7 +59,6 @@ describe('PaymentRedirect', () => {
|
|||
number: '0701234567',
|
||||
amount: 49,
|
||||
})
|
||||
mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
|
||||
})
|
||||
|
||||
it('renders heading and amount', async () => {
|
||||
|
|
@ -94,7 +81,7 @@ describe('PaymentRedirect', () => {
|
|||
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||
expect(wrapper.text()).toContain(orderId)
|
||||
expect(wrapper.text()).toContain(
|
||||
'fylls i automatiskt via QR-kod eller länk',
|
||||
'Ange beställnings-ID ovan som meddelande i Swish-appen',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -106,40 +93,13 @@ describe('PaymentRedirect', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('renders QR code after fetching swish info', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
|
||||
})
|
||||
expect(mockToDataURL).toHaveBeenCalledTimes(1)
|
||||
// Regression guard: the QR must use a spec-compliant 4-module quiet zone
|
||||
// and pure black-on-white so the Swish app can scan it off a screen.
|
||||
expect(mockToDataURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining('app.swish.nu'),
|
||||
expect.objectContaining({
|
||||
margin: 4,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a Swish payment link', async () => {
|
||||
const { wrapper } = await mountPage('test-order', 'ABC123')
|
||||
await vi.waitFor(() => {
|
||||
const link = wrapper.find('.payment__swish-link')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.attributes('href')).toContain('app.swish.nu')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog after clicking pay button', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
|
||||
expect(wrapper.text()).toContain('0701234567')
|
||||
|
|
@ -150,15 +110,15 @@ describe('PaymentRedirect', () => {
|
|||
it('can cancel confirmation dialog', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Avbryt')
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__confirm-cancel').trigger('click')
|
||||
await wrapper.find('.btn--ghost').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Swisha till')
|
||||
expect(wrapper.text()).not.toContain('Avbryt')
|
||||
|
|
@ -177,15 +137,16 @@ describe('PaymentRedirect', () => {
|
|||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
||||
|
||||
expect(mockPayOrder).toHaveBeenCalledWith('order-1')
|
||||
})
|
||||
|
|
@ -195,15 +156,16 @@ describe('PaymentRedirect', () => {
|
|||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
|
||||
|
|
@ -222,15 +184,16 @@ describe('PaymentRedirect', () => {
|
|||
|
||||
const { wrapper, router } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||
})
|
||||
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
|
|
|
|||
|
|
@ -41,16 +41,6 @@ describe('PrivacyPolicyPage', () => {
|
|||
expect(wrapper.text()).toContain('varken vi eller obehöriga')
|
||||
})
|
||||
|
||||
it('describes web analytics', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(PrivacyPolicyPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Webbstatistik')
|
||||
expect(wrapper.text()).toContain('analytics.bilhej.se')
|
||||
expect(wrapper.text()).toContain('IP-adresser')
|
||||
})
|
||||
|
||||
it('links to contact email and contact page', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(PrivacyPolicyPage, {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import router, { scrollBehavior } from '@/router'
|
||||
import router from '@/router'
|
||||
|
||||
describe('Router', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -8,25 +8,6 @@ describe('Router', () => {
|
|||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('scrolls to top on route change without hash', () => {
|
||||
const position = scrollBehavior(
|
||||
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
|
||||
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
|
||||
null,
|
||||
)
|
||||
expect(position).toEqual({ top: 0, left: 0 })
|
||||
})
|
||||
|
||||
it('restores saved position when using browser back', () => {
|
||||
const saved = { top: 120, left: 0 }
|
||||
const position = scrollBehavior(
|
||||
{ hash: '' } as Parameters<typeof scrollBehavior>[0],
|
||||
{ hash: '' } as Parameters<typeof scrollBehavior>[1],
|
||||
saved,
|
||||
)
|
||||
expect(position).toBe(saved)
|
||||
})
|
||||
|
||||
it('resolves / to HomePage', async () => {
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
|
@ -214,64 +195,6 @@ describe('Router guards', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Router guards — expired tokens', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('redirects expired-token user from /orders to /logga-in with redirect query', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/orders')
|
||||
})
|
||||
|
||||
it('clears the expired token from localStorage on redirect', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows access with a token whose exp is in the future', async () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
})
|
||||
|
||||
it('lets expired-token user open /logga-in instead of bouncing to home', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/logga-in')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('lets expired-token user open /registrera instead of bouncing to home', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/registrera')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('register')
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
|
|
|
|||
|
|
@ -212,52 +212,6 @@ describe('authStore', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('authStore.isTokenExpired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns true when there is no token', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for a token with an expired exp claim', () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a token with a future exp claim', () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a token without an exp claim', () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true after logout clears the token', async () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
const store = useAuthStore()
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
|
||||
store.logout()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mockLogout = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
return {
|
||||
mockLogout,
|
||||
mockPush,
|
||||
mockAuth: { isAuthenticated: true, logout: mockLogout },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => mocks.mockAuth,
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { fullPath: '/orders' } },
|
||||
push: mocks.mockPush,
|
||||
},
|
||||
}))
|
||||
|
||||
import { request, ApiError, isSessionExpired, isForbidden } from '@/api/client'
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
mocks.mockLogout.mockClear()
|
||||
mocks.mockPush.mockClear()
|
||||
mocks.mockAuth.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('logs out and redirects to login on 401 from a protected endpoint', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
|
||||
await expect(request('/orders')).rejects.toThrow('Din session har löpt ut.')
|
||||
|
||||
expect(mocks.mockLogout).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.mockPush).toHaveBeenCalledWith({
|
||||
name: 'login',
|
||||
query: { redirect: '/orders' },
|
||||
})
|
||||
})
|
||||
|
||||
it('still throws ApiError with 401 status after handling expired session', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
|
||||
try {
|
||||
await request('/orders')
|
||||
throw new Error('should have thrown')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect((err as ApiError).status).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not log out on 401 from an auth endpoint (wrong credentials)', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||
)
|
||||
|
||||
await expect(request('/auth/login', { method: 'POST' })).rejects.toThrow(
|
||||
'Felaktig e-post eller lösenord',
|
||||
)
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not log out on 403 (forbidden is not session expiry)', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(403, { message: 'Du har inte behörighet' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', 'valid-token')
|
||||
|
||||
await expect(request('/admin/orders')).rejects.toThrow(
|
||||
'Du har inte behörighet',
|
||||
)
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not redirect when there is no token on 401', async () => {
|
||||
mocks.mockAuth.isAuthenticated = false
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
|
||||
await expect(request('/orders')).rejects.toThrow()
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isSessionExpired returns true only for a 401 ApiError', () => {
|
||||
expect(isSessionExpired(new ApiError(401, 'x'))).toBe(true)
|
||||
expect(isSessionExpired(new ApiError(403, 'x'))).toBe(false)
|
||||
expect(isSessionExpired(new ApiError(500, 'x'))).toBe(false)
|
||||
expect(isSessionExpired(new Error('x'))).toBe(false)
|
||||
expect(isSessionExpired(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('isForbidden returns true only for a 403 ApiError', () => {
|
||||
expect(isForbidden(new ApiError(403, 'x'))).toBe(true)
|
||||
expect(isForbidden(new ApiError(401, 'x'))).toBe(false)
|
||||
expect(isForbidden(new Error('x'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mockRequest: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
request: mocks.mockRequest,
|
||||
}))
|
||||
|
||||
import {
|
||||
createGuestOrder,
|
||||
fetchGuestOrder,
|
||||
payGuestOrder,
|
||||
} from '@/api/guestOrders'
|
||||
|
||||
describe('guestOrders API', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mocks.mockRequest.mockReset()
|
||||
})
|
||||
|
||||
describe('createGuestOrder', () => {
|
||||
it('sends a POST to /guest-orders with plate, letterText, and email', async () => {
|
||||
const mockOrder = {
|
||||
id: 'order-1',
|
||||
plate: 'ABC123',
|
||||
letterText: 'Hej',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
guestToken: 'token-abc',
|
||||
}
|
||||
mocks.mockRequest.mockResolvedValue(mockOrder)
|
||||
|
||||
const result = await createGuestOrder('ABC123', 'Hej', 'test@example.se')
|
||||
|
||||
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plate: 'ABC123',
|
||||
letterText: 'Hej',
|
||||
email: 'test@example.se',
|
||||
}),
|
||||
})
|
||||
expect(result).toEqual(mockOrder)
|
||||
})
|
||||
|
||||
it('propagates errors from the API client', async () => {
|
||||
mocks.mockRequest.mockRejectedValue(new Error('Server error'))
|
||||
|
||||
await expect(
|
||||
createGuestOrder('ABC123', 'Hej', 'test@example.se'),
|
||||
).rejects.toThrow('Server error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchGuestOrder', () => {
|
||||
it('sends a GET to /guest-orders/:token', async () => {
|
||||
const mockOrder = {
|
||||
id: 'order-1',
|
||||
plate: 'ABC123',
|
||||
letterText: 'Hej',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
guestToken: 'token-abc',
|
||||
}
|
||||
mocks.mockRequest.mockResolvedValue(mockOrder)
|
||||
|
||||
const result = await fetchGuestOrder('token-abc')
|
||||
|
||||
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders/token-abc')
|
||||
expect(result).toEqual(mockOrder)
|
||||
})
|
||||
})
|
||||
|
||||
describe('payGuestOrder', () => {
|
||||
it('sends a POST to /guest-orders/:token/pay', async () => {
|
||||
const mockOrder = {
|
||||
id: 'order-1',
|
||||
plate: 'ABC123',
|
||||
letterText: 'Hej',
|
||||
status: 'processing',
|
||||
trackingId: null,
|
||||
amountPaid: 49,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
guestToken: 'token-abc',
|
||||
}
|
||||
mocks.mockRequest.mockResolvedValue(mockOrder)
|
||||
|
||||
const result = await payGuestOrder('token-abc')
|
||||
|
||||
expect(mocks.mockRequest).toHaveBeenCalledWith(
|
||||
'/guest-orders/token-abc/pay',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
expect(result).toEqual(mockOrder)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { buildSwishPaymentUrl } from '@/api/payment'
|
||||
|
||||
describe('buildSwishPaymentUrl', () => {
|
||||
it('normalises Swedish national format to international', () => {
|
||||
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
|
||||
'sw=46701234567',
|
||||
)
|
||||
})
|
||||
|
||||
it('strips a leading + from international format', () => {
|
||||
const url = buildSwishPaymentUrl('+46701234567', 49, 'test')
|
||||
expect(url).toContain('sw=46701234567')
|
||||
expect(url).not.toContain('sw=%2B')
|
||||
expect(url).not.toContain('sw=+')
|
||||
})
|
||||
|
||||
it('leaves already-international numbers unchanged', () => {
|
||||
expect(buildSwishPaymentUrl('46701234567', 49, 'test')).toContain(
|
||||
'sw=46701234567',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves Swish Business numbers (123…) unchanged', () => {
|
||||
expect(buildSwishPaymentUrl('1234567890', 49, 'test')).toContain(
|
||||
'sw=1234567890',
|
||||
)
|
||||
})
|
||||
|
||||
it('strips whitespace from the number', () => {
|
||||
expect(buildSwishPaymentUrl('070 123 45 67', 49, 'test')).toContain(
|
||||
'sw=46701234567',
|
||||
)
|
||||
})
|
||||
|
||||
it('includes the amount with two decimal places in amt', () => {
|
||||
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
|
||||
'amt=49.00',
|
||||
)
|
||||
})
|
||||
|
||||
it('URL-encodes the message in the msg parameter', () => {
|
||||
const url = buildSwishPaymentUrl('0701234567', 49, 'ABC 123')
|
||||
expect(url).toContain('msg=ABC+123')
|
||||
})
|
||||
|
||||
it('uses the correct Swish C2B base URL', () => {
|
||||
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
|
||||
'https://app.swish.nu/1/p/sw/?',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import {
|
||||
getUmamiConfig,
|
||||
initUmamiAnalytics,
|
||||
trackUmamiPageview,
|
||||
} from '@/utils/umami'
|
||||
|
||||
describe('umami', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = ''
|
||||
delete window.umami
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns null when website id is unset', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
|
||||
expect(getUmamiConfig()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns config when website id is set', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '11111111-2222-3333-4444-555555555555')
|
||||
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', '')
|
||||
expect(getUmamiConfig()).toEqual({
|
||||
websiteId: '11111111-2222-3333-4444-555555555555',
|
||||
scriptUrl: 'https://analytics.bilhej.se/script.js',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses custom script url when provided', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
|
||||
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://example.test/script.js')
|
||||
expect(getUmamiConfig()?.scriptUrl).toBe('https://example.test/script.js')
|
||||
})
|
||||
|
||||
it('does not inject script when website id is unset', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
initUmamiAnalytics(router)
|
||||
expect(document.querySelector('script[data-website-id]')).toBeNull()
|
||||
})
|
||||
|
||||
it('injects script with auto-track disabled when configured', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
initUmamiAnalytics(router)
|
||||
const script = document.querySelector('script[data-website-id]')
|
||||
expect(script?.getAttribute('data-website-id')).toBe('test-id')
|
||||
expect(script?.getAttribute('data-auto-track')).toBe('false')
|
||||
expect(script?.getAttribute('src')).toContain('script.js')
|
||||
})
|
||||
|
||||
it('trackUmamiPageview forwards url to umami', () => {
|
||||
const track = vi.fn()
|
||||
window.umami = { track }
|
||||
trackUmamiPageview('/orders')
|
||||
expect(track).toHaveBeenCalledOnce()
|
||||
const mapper = track.mock.calls[0][0] as (
|
||||
props: Record<string, unknown>,
|
||||
) => Record<string, unknown>
|
||||
expect(mapper({ referrer: 'x' })).toEqual({ referrer: 'x', url: '/orders' })
|
||||
})
|
||||
})
|
||||
|
|
@ -8,11 +8,7 @@ export interface AdminOrder {
|
|||
status: string
|
||||
trackingId: string | null
|
||||
amountPaid: number | null
|
||||
shippedAt: string | null
|
||||
adminNotes: string | null
|
||||
createdAt: string
|
||||
allowedStatuses: string[]
|
||||
canRegisterShipment: boolean
|
||||
}
|
||||
|
||||
export function fetchAllOrders(): Promise<AdminOrder[]> {
|
||||
|
|
@ -29,23 +25,12 @@ export function updateOrderStatus(
|
|||
})
|
||||
}
|
||||
|
||||
export function registerShipment(
|
||||
export function updateTracking(
|
||||
orderId: string,
|
||||
trackingInput: string,
|
||||
notifyCustomer = true,
|
||||
trackingId: string | null,
|
||||
): Promise<AdminOrder> {
|
||||
return request<AdminOrder>(`/admin/orders/${orderId}/register-shipment`, {
|
||||
return request<AdminOrder>(`/admin/orders/${orderId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ trackingInput, notifyCustomer }),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAdminNotes(
|
||||
orderId: string,
|
||||
adminNotes: string | null,
|
||||
): Promise<AdminOrder> {
|
||||
return request<AdminOrder>(`/admin/orders/${orderId}/notes`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ adminNotes }),
|
||||
body: JSON.stringify({ trackingId }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import { useAuthStore } from '@/stores/authStore'
|
||||
import router from '@/router'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
|
@ -13,27 +10,10 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export function isSessionExpired(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 401
|
||||
}
|
||||
|
||||
export function isForbidden(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 403
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
function handleExpiredSession(): void {
|
||||
const auth = useAuthStore()
|
||||
if (auth.isAuthenticated) {
|
||||
auth.logout()
|
||||
const redirect = router.currentRoute.value.fullPath
|
||||
router.push({ name: 'login', query: { redirect } })
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
|
|
@ -54,9 +34,6 @@ export async function request<T>(
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && !url.startsWith('/auth/')) {
|
||||
handleExpiredSession()
|
||||
}
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new ApiError(response.status, body.message || 'Något gick fel')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { request } from './client'
|
||||
|
||||
/**
|
||||
* Guest order — placed and paid for without an account. {@link guestToken}
|
||||
* is the only credential the client holds; it is returned only on create
|
||||
* and from a by-token lookup. Pass it as the query string on the next
|
||||
* page so a refresh keeps the session alive.
|
||||
*/
|
||||
export interface GuestOrder {
|
||||
id: string
|
||||
plate: string
|
||||
letterText: string
|
||||
status: string
|
||||
trackingId: string | null
|
||||
amountPaid: number | null
|
||||
createdAt: string
|
||||
guestToken: string
|
||||
}
|
||||
|
||||
export function createGuestOrder(
|
||||
plate: string,
|
||||
letterText: string,
|
||||
email: string,
|
||||
): Promise<GuestOrder> {
|
||||
return request<GuestOrder>('/guest-orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ plate, letterText, email }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchGuestOrder(token: string): Promise<GuestOrder> {
|
||||
return request<GuestOrder>(`/guest-orders/${token}`)
|
||||
}
|
||||
|
||||
export function payGuestOrder(token: string): Promise<GuestOrder> {
|
||||
return request<GuestOrder>(`/guest-orders/${token}/pay`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
|
@ -15,47 +15,3 @@ export function payOrder(orderId: string): Promise<Order> {
|
|||
export function fetchSwishInfo(): Promise<SwishInfo> {
|
||||
return request<SwishInfo>('/payment/swish-info')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a pre-filled Swish payment URL.
|
||||
*
|
||||
* On mobile, tapping this URL opens the Swish app with the amount and
|
||||
* message pre-filled. On desktop, embed it in a QR code for the user
|
||||
* to scan with their phone.
|
||||
*
|
||||
* Uses the Swish "C2B pre-fill" URL scheme documented at
|
||||
* https://developer.swish.nu — no Swish Commerce API certificate required.
|
||||
* The `sw` parameter accepts either a phone number or a Swish Business
|
||||
* number (123…). Phone numbers in Swedish national format (leading 0)
|
||||
* are normalised to international format (46…).
|
||||
*/
|
||||
export function buildSwishPaymentUrl(
|
||||
swishNumber: string,
|
||||
amount: number,
|
||||
message: string,
|
||||
): string {
|
||||
const payee = normalizeSwishNumber(swishNumber)
|
||||
const params = new URLSearchParams({
|
||||
sw: payee,
|
||||
amt: amount.toFixed(2),
|
||||
msg: message,
|
||||
})
|
||||
return `https://app.swish.nu/1/p/sw/?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a Swish number to the format the Swish URL expects.
|
||||
* - 123… (Swish Business number) → unchanged
|
||||
* - 46… (already international) → unchanged
|
||||
* - 0… (Swedish national format) → 46 + rest without leading 0
|
||||
* - +46… (international with plus) → 46… (the plus is stripped first)
|
||||
*/
|
||||
function normalizeSwishNumber(number: string): string {
|
||||
// Strip whitespace and a leading "+": a number stored as "+46 70 …" would
|
||||
// otherwise miss every prefix check and leak a "+" into the `sw` param.
|
||||
const trimmed = number.replace(/[\s+]/g, '')
|
||||
if (trimmed.startsWith('123')) return trimmed
|
||||
if (trimmed.startsWith('46')) return trimmed
|
||||
if (trimmed.startsWith('0')) return '46' + trimmed.slice(1)
|
||||
return trimmed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,10 +94,6 @@ a {
|
|||
/* transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
|
||||
/* layout */
|
||||
--page-gutter: var(--space-lg);
|
||||
--header-height: 3.25rem;
|
||||
}
|
||||
|
||||
/* ── Body ────────────────────────────────────────────────────────────── */
|
||||
|
|
@ -411,34 +407,3 @@ a[href]:hover {
|
|||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Responsive (customer-facing; max 639px = phone) ─────────────────── */
|
||||
@media (max-width: 639px) {
|
||||
:root {
|
||||
--page-gutter: var(--space-md);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.container,
|
||||
.container--narrow,
|
||||
.container--wide {
|
||||
padding-inline: var(--page-gutter);
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.btn--block-sm {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.btn--block-sm {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { RouterLink } from 'vue-router'
|
|||
.app-footer__inner {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-xl) var(--page-gutter);
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
|
@ -66,15 +66,4 @@ import { RouterLink } from 'vue-router'
|
|||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.app-footer__links {
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.app-footer__inner {
|
||||
padding-block: var(--space-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
|
|
@ -7,10 +7,10 @@ const router = useRouter()
|
|||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const isSettingsActive = computed(
|
||||
() => route.name === 'change-email' || route.name === 'change-password',
|
||||
() =>
|
||||
route.name === 'change-email' || route.name === 'change-password',
|
||||
)
|
||||
const settingsOpen = ref(false)
|
||||
const menuOpen = ref(false)
|
||||
const settingsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function toggleSettings() {
|
||||
|
|
@ -21,67 +21,30 @@ function closeSettings() {
|
|||
settingsOpen.value = false
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value
|
||||
if (!menuOpen.value) {
|
||||
closeSettings()
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen.value = false
|
||||
closeSettings()
|
||||
}
|
||||
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!settingsRef.value?.contains(event.target as Node)) {
|
||||
settingsOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function handleNavClick() {
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
closeMenu()
|
||||
auth.logout()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
closeMenu()
|
||||
},
|
||||
)
|
||||
|
||||
watch(menuOpen, (open) => {
|
||||
document.body.classList.toggle('nav-menu-open', open)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.body.classList.remove('nav-menu-open')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header" :class="{ 'app-header--menu-open': menuOpen }">
|
||||
<header class="app-header">
|
||||
<div class="app-header__inner">
|
||||
<RouterLink to="/" class="app-header__logo" @click="handleNavClick">
|
||||
<RouterLink to="/" class="app-header__logo">
|
||||
<svg
|
||||
class="app-header__logo-icon"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -108,62 +71,13 @@ onUnmounted(() => {
|
|||
</svg>
|
||||
Bilhej
|
||||
</RouterLink>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="app-header__menu-toggle"
|
||||
:aria-expanded="menuOpen"
|
||||
aria-controls="app-header-nav"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<span class="visually-hidden">{{
|
||||
menuOpen ? 'Stäng meny' : 'Öppna meny'
|
||||
}}</span>
|
||||
<svg
|
||||
v-if="!menuOpen"
|
||||
class="app-header__menu-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4 7h16M4 12h16M4 17h16"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="app-header__menu-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<nav id="app-header-nav" class="app-header__nav">
|
||||
<RouterLink to="/" class="app-header__link" @click="handleNavClick"
|
||||
>Hem</RouterLink
|
||||
>
|
||||
<nav class="app-header__nav">
|
||||
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
|
||||
<template v-if="!auth.isAuthenticated">
|
||||
<RouterLink
|
||||
to="/logga-in"
|
||||
class="app-header__link"
|
||||
@click="handleNavClick"
|
||||
<RouterLink to="/logga-in" class="app-header__link"
|
||||
>Logga in</RouterLink
|
||||
>
|
||||
<RouterLink
|
||||
to="/registrera"
|
||||
class="app-header__link"
|
||||
@click="handleNavClick"
|
||||
<RouterLink to="/registrera" class="app-header__link"
|
||||
>Registrera</RouterLink
|
||||
>
|
||||
</template>
|
||||
|
|
@ -171,38 +85,12 @@ onUnmounted(() => {
|
|||
<RouterLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin"
|
||||
class="app-header__link"
|
||||
@click="handleNavClick"
|
||||
class="app-header__link app-header__link--admin"
|
||||
>Admin</RouterLink
|
||||
>
|
||||
<RouterLink
|
||||
to="/orders"
|
||||
class="app-header__link"
|
||||
@click="handleNavClick"
|
||||
<RouterLink to="/orders" class="app-header__link"
|
||||
>Mina beställningar</RouterLink
|
||||
>
|
||||
<RouterLink
|
||||
to="/andra-epost"
|
||||
class="app-header__link app-header__link--settings-mobile"
|
||||
:class="{
|
||||
'app-header__link--active-settings':
|
||||
route.name === 'change-email',
|
||||
}"
|
||||
@click="handleNavClick"
|
||||
>
|
||||
Byt e-postadress
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/andra-losenord"
|
||||
class="app-header__link app-header__link--settings-mobile"
|
||||
:class="{
|
||||
'app-header__link--active-settings':
|
||||
route.name === 'change-password',
|
||||
}"
|
||||
@click="handleNavClick"
|
||||
>
|
||||
Byt lösenord
|
||||
</RouterLink>
|
||||
<div ref="settingsRef" class="app-header__settings">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -277,10 +165,9 @@ onUnmounted(() => {
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 0.875rem var(--page-gutter);
|
||||
padding: 0.875rem var(--space-lg);
|
||||
}
|
||||
|
||||
.app-header__logo {
|
||||
|
|
@ -291,7 +178,6 @@ onUnmounted(() => {
|
|||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-header__logo-icon {
|
||||
|
|
@ -299,26 +185,6 @@ onUnmounted(() => {
|
|||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.app-header__menu-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
padding: 0;
|
||||
color: var(--color-ink);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-header__menu-icon {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -343,23 +209,21 @@ onUnmounted(() => {
|
|||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.app-header__link--active-settings {
|
||||
color: var(--color-primary-dark);
|
||||
.app-header__link--admin {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header__link--settings-mobile {
|
||||
display: none;
|
||||
.app-header__link--admin:hover {
|
||||
background: #e9d5ff;
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.app-header__email {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.8125rem;
|
||||
padding: 0 0.5rem;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__settings {
|
||||
|
|
@ -449,69 +313,4 @@ onUnmounted(() => {
|
|||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.app-header__menu-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
gap: 0.25rem;
|
||||
padding: var(--space-sm) 0 var(--space-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.app-header--menu-open .app-header__nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-header__link,
|
||||
.app-header__settings-trigger,
|
||||
.app-header__logout {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.app-header__link--settings-mobile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-header__settings {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__email {
|
||||
order: 10;
|
||||
max-width: none;
|
||||
padding: var(--space-sm) 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.app-header__logout {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.nav-menu-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { AdminOrder } from '@/api/admin'
|
||||
import { postNordTrackingUrl } from '@/constants/orderStatus'
|
||||
|
||||
defineProps<{
|
||||
order: AdminOrder
|
||||
trackingInput: string
|
||||
adminNotes: string
|
||||
notifyCustomer: boolean
|
||||
trackingError: string
|
||||
notesError: string
|
||||
registering: boolean
|
||||
savingNotes: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:trackingInput': [value: string]
|
||||
'update:adminNotes': [value: string]
|
||||
'update:notifyCustomer': [value: boolean]
|
||||
registerShipment: []
|
||||
saveNotes: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin__expanded-inner">
|
||||
<ol v-if="order.status === 'processing'" class="admin__checklist">
|
||||
<li>Hämta ägaradress via Transportstyrelsen</li>
|
||||
<li>Skriv ut brevet och lägg i kuvert</li>
|
||||
<li>Skicka med PostNord och få spårnings-ID</li>
|
||||
<li>Registrera utskick nedan</li>
|
||||
</ol>
|
||||
|
||||
<div v-if="order.canRegisterShipment" class="admin__section">
|
||||
<div class="admin__section-header">
|
||||
<span class="admin__section-label">Registrera utskick</span>
|
||||
<a
|
||||
v-if="order.trackingId"
|
||||
class="admin__tracking-link"
|
||||
:href="postNordTrackingUrl(order.trackingId)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.stop
|
||||
>
|
||||
Spåra hos PostNord
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="admin__section-hint">
|
||||
Klistra in spårnings-ID eller PostNord-länk. Vid beställningar som
|
||||
hanteras markeras brevet som skickat och kunden kan få e-post.
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="trackingError"
|
||||
class="message message--error admin__tracking-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ trackingError }}
|
||||
</p>
|
||||
|
||||
<div class="admin__tracking-row">
|
||||
<label :for="`tracking-${order.id}`" class="visually-hidden"
|
||||
>Spårnings-ID</label
|
||||
>
|
||||
<input
|
||||
:id="`tracking-${order.id}`"
|
||||
class="admin__tracking-input"
|
||||
type="text"
|
||||
:value="trackingInput"
|
||||
placeholder="PN... eller PostNord-länk"
|
||||
@input="
|
||||
emit(
|
||||
'update:trackingInput',
|
||||
($event.target as HTMLInputElement).value,
|
||||
)
|
||||
"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
class="btn btn--primary btn--sm"
|
||||
:disabled="registering"
|
||||
@click.stop="emit('registerShipment')"
|
||||
>
|
||||
{{ registering ? 'Registrerar...' : 'Registrera utskick' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label v-if="order.status === 'processing'" class="admin__notify">
|
||||
<input
|
||||
:checked="notifyCustomer"
|
||||
type="checkbox"
|
||||
@change="
|
||||
emit(
|
||||
'update:notifyCustomer',
|
||||
($event.target as HTMLInputElement).checked,
|
||||
)
|
||||
"
|
||||
@click.stop
|
||||
/>
|
||||
Skicka e-post till kund
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin__section">
|
||||
<span class="admin__section-label">Interna anteckningar</span>
|
||||
<p v-if="notesError" class="message message--error" role="alert">
|
||||
{{ notesError }}
|
||||
</p>
|
||||
<textarea
|
||||
:id="`notes-${order.id}`"
|
||||
class="admin__notes-input"
|
||||
rows="3"
|
||||
placeholder="T.ex. TS-begäran skickad..."
|
||||
:value="adminNotes"
|
||||
@input="
|
||||
emit(
|
||||
'update:adminNotes',
|
||||
($event.target as HTMLTextAreaElement).value,
|
||||
)
|
||||
"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
class="btn btn--ghost btn--sm admin__notes-save"
|
||||
:disabled="savingNotes"
|
||||
@click.stop="emit('saveNotes')"
|
||||
>
|
||||
{{ savingNotes ? 'Sparar...' : 'Spara anteckningar' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { AdminOrder } from '@/api/admin'
|
||||
import { shortOrderId } from '@/utils/orderDisplay'
|
||||
|
||||
defineProps<{
|
||||
order: AdminOrder | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="order" class="admin-modal-overlay" @click.self="emit('close')">
|
||||
<div
|
||||
class="admin-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-message-modal-title"
|
||||
>
|
||||
<div class="admin-modal__header">
|
||||
<h2 id="admin-message-modal-title" class="admin-modal__title">
|
||||
Brevtext
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-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>
|
||||
<p class="admin-modal__meta">
|
||||
{{ order.plate }} · {{ shortOrderId(order.id) }}
|
||||
</p>
|
||||
<div class="admin-modal__body">
|
||||
{{ order.letterText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { AdminOrder } from '@/api/admin'
|
||||
import {
|
||||
ORDER_STATUS_BADGE,
|
||||
ORDER_STATUS_LABELS,
|
||||
} from '@/constants/orderStatus'
|
||||
import { formatOrderDate, shortOrderId } from '@/utils/orderDisplay'
|
||||
import AdminOrderDetailPanel from '@/components/admin/AdminOrderDetailPanel.vue'
|
||||
|
||||
defineProps<{
|
||||
orders: AdminOrder[]
|
||||
expandedOrderId: string | null
|
||||
statusError: string
|
||||
trackingError: string
|
||||
notesError: string
|
||||
trackingInputValues: Record<string, string>
|
||||
adminNotesValues: Record<string, string>
|
||||
notifyCustomerValues: Record<string, boolean>
|
||||
savingNotesId: string | null
|
||||
registeringId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleExpand: [orderId: string]
|
||||
openMessage: [order: AdminOrder]
|
||||
statusChange: [orderId: string, status: string]
|
||||
registerShipment: [orderId: string]
|
||||
saveNotes: [orderId: string]
|
||||
'update:trackingInput': [orderId: string, value: string]
|
||||
'update:adminNotes': [orderId: string, value: string]
|
||||
'update:notifyCustomer': [orderId: string, value: boolean]
|
||||
}>()
|
||||
|
||||
function isStatusDropdownDisabled(order: AdminOrder): boolean {
|
||||
return order.allowedStatuses.length <= 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
v-if="statusError"
|
||||
class="message message--error admin__status-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ statusError }}
|
||||
</p>
|
||||
|
||||
<div class="admin__table-wrap">
|
||||
<table class="admin__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="admin__th-expand" scope="col">
|
||||
<span class="visually-hidden">Visa detaljer</span>
|
||||
</th>
|
||||
<th class="admin__th-date">Datum</th>
|
||||
<th class="admin__th-id" title="Beställnings-ID">ID</th>
|
||||
<th class="admin__th-email">E-post</th>
|
||||
<th class="admin__th-plate">Regnr</th>
|
||||
<th class="admin__th-message" title="Meddelande">Brev</th>
|
||||
<th class="admin__th-status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="order in orders" :key="order.id">
|
||||
<tr
|
||||
class="admin__row"
|
||||
:class="{
|
||||
'admin__row--expanded': expandedOrderId === order.id,
|
||||
'admin__row--todo': order.status === 'processing',
|
||||
}"
|
||||
:aria-expanded="expandedOrderId === order.id"
|
||||
:title="
|
||||
expandedOrderId === order.id
|
||||
? 'Klicka för att dölja detaljer'
|
||||
: 'Klicka för att visa utskick och detaljer'
|
||||
"
|
||||
@click="emit('toggleExpand', order.id)"
|
||||
>
|
||||
<td class="admin__expand-cell">
|
||||
<span
|
||||
class="admin__expand-icon"
|
||||
:class="{
|
||||
'admin__expand-icon--open': expandedOrderId === order.id,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline
|
||||
:points="
|
||||
expandedOrderId === order.id
|
||||
? '6 9 12 15 18 9'
|
||||
: '9 6 15 12 9 18'
|
||||
"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin__td-date">
|
||||
{{ formatOrderDate(order.createdAt) }}
|
||||
</td>
|
||||
<td class="admin__order-id" :title="order.id">
|
||||
{{ shortOrderId(order.id) }}
|
||||
</td>
|
||||
<td class="admin__email" :title="order.email">{{ order.email }}</td>
|
||||
<td class="admin__plate">{{ order.plate }}</td>
|
||||
<td class="admin__message-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--ghost btn--sm admin__message-btn"
|
||||
@click.stop="emit('openMessage', order)"
|
||||
>
|
||||
Visa meddelande
|
||||
</button>
|
||||
</td>
|
||||
<td class="admin__status-cell">
|
||||
<select
|
||||
class="admin__status-select"
|
||||
:class="ORDER_STATUS_BADGE[order.status] || 'badge--muted'"
|
||||
:value="order.status"
|
||||
:disabled="isStatusDropdownDisabled(order)"
|
||||
@change="
|
||||
emit(
|
||||
'statusChange',
|
||||
order.id,
|
||||
($event.target as HTMLSelectElement).value,
|
||||
)
|
||||
"
|
||||
@click.stop
|
||||
>
|
||||
<option v-for="s in order.allowedStatuses" :key="s" :value="s">
|
||||
{{ ORDER_STATUS_LABELS[s] }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedOrderId === order.id" class="admin__expanded-row">
|
||||
<td :colspan="7">
|
||||
<AdminOrderDetailPanel
|
||||
:order="order"
|
||||
:tracking-input="
|
||||
trackingInputValues[order.id] ?? order.trackingId ?? ''
|
||||
"
|
||||
:admin-notes="adminNotesValues[order.id] ?? ''"
|
||||
:notify-customer="notifyCustomerValues[order.id] ?? true"
|
||||
:tracking-error="trackingError"
|
||||
:notes-error="notesError"
|
||||
:registering="registeringId === order.id"
|
||||
:saving-notes="savingNotesId === order.id"
|
||||
@update:tracking-input="
|
||||
emit('update:trackingInput', order.id, $event)
|
||||
"
|
||||
@update:admin-notes="
|
||||
emit('update:adminNotes', order.id, $event)
|
||||
"
|
||||
@update:notify-customer="
|
||||
emit('update:notifyCustomer', order.id, $event)
|
||||
"
|
||||
@register-shipment="emit('registerShipment', order.id)"
|
||||
@save-notes="emit('saveNotes', order.id)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { AdminOrderFilter } from '@/composables/useAdminOrders'
|
||||
|
||||
defineProps<{
|
||||
total: number
|
||||
todo: number
|
||||
paid: number
|
||||
pending: number
|
||||
}>()
|
||||
|
||||
const activeFilterModel = defineModel<AdminOrderFilter>('activeFilter', {
|
||||
required: true,
|
||||
})
|
||||
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin__stats">
|
||||
<button
|
||||
type="button"
|
||||
class="admin__stat"
|
||||
:class="{ 'admin__stat--active': activeFilterModel === 'all' }"
|
||||
@click="activeFilterModel = 'all'"
|
||||
>
|
||||
<span class="admin__stat-value">{{ total }}</span>
|
||||
<span class="admin__stat-label">Totalt</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin__stat"
|
||||
:class="{ 'admin__stat--active': activeFilterModel === 'processing' }"
|
||||
@click="activeFilterModel = 'processing'"
|
||||
>
|
||||
<span class="admin__stat-value">{{ todo }}</span>
|
||||
<span class="admin__stat-label">Att göra</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin__stat"
|
||||
:class="{ 'admin__stat--active': activeFilterModel === 'paid_group' }"
|
||||
@click="activeFilterModel = 'paid_group'"
|
||||
>
|
||||
<span class="admin__stat-value">{{ paid }}</span>
|
||||
<span class="admin__stat-label">Betalda</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin__stat"
|
||||
:class="{
|
||||
'admin__stat--active': activeFilterModel === 'pending_payment',
|
||||
}"
|
||||
@click="activeFilterModel = 'pending_payment'"
|
||||
>
|
||||
<span class="admin__stat-value">{{ pending }}</span>
|
||||
<span class="admin__stat-label">Väntar</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin__toolbar">
|
||||
<label for="admin-order-search" class="admin__search-label"
|
||||
>Sök beställnings-ID eller regnr</label
|
||||
>
|
||||
<input
|
||||
id="admin-order-search"
|
||||
v-model="searchQuery"
|
||||
class="admin__search-input"
|
||||
type="search"
|
||||
placeholder="t.ex. c1eebc99 eller ABC123"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
import { ref, reactive, type Ref } from 'vue'
|
||||
import { ApiError, isSessionExpired } from '@/api/client'
|
||||
import {
|
||||
updateOrderStatus,
|
||||
registerShipment,
|
||||
updateAdminNotes,
|
||||
type AdminOrder,
|
||||
} from '@/api/admin'
|
||||
|
||||
export function useAdminOrderActions(
|
||||
orders: Ref<AdminOrder[]>,
|
||||
replaceOrder: (updated: AdminOrder) => void,
|
||||
) {
|
||||
const expandedOrderId = ref<string | null>(null)
|
||||
const statusError = ref('')
|
||||
const trackingError = ref('')
|
||||
const notesError = ref('')
|
||||
const savingNotesId = ref<string | null>(null)
|
||||
const registeringId = ref<string | null>(null)
|
||||
const messageModalOrder = ref<AdminOrder | null>(null)
|
||||
|
||||
const trackingInputValues = reactive<Record<string, string>>({})
|
||||
const adminNotesValues = reactive<Record<string, string>>({})
|
||||
const notifyCustomerValues = reactive<Record<string, boolean>>({})
|
||||
|
||||
function findOrder(orderId: string): AdminOrder | undefined {
|
||||
return orders.value.find((o) => o.id === orderId)
|
||||
}
|
||||
|
||||
function openMessageModal(order: AdminOrder) {
|
||||
messageModalOrder.value = order
|
||||
}
|
||||
|
||||
function closeMessageModal() {
|
||||
messageModalOrder.value = null
|
||||
}
|
||||
|
||||
function toggleExpand(orderId: string) {
|
||||
if (expandedOrderId.value === orderId) {
|
||||
expandedOrderId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
expandedOrderId.value = orderId
|
||||
const order = findOrder(orderId)
|
||||
if (!order) return
|
||||
|
||||
if (!(orderId in trackingInputValues)) {
|
||||
trackingInputValues[orderId] = order.trackingId ?? ''
|
||||
}
|
||||
if (!(orderId in adminNotesValues)) {
|
||||
adminNotesValues[orderId] = order.adminNotes ?? ''
|
||||
}
|
||||
if (!(orderId in notifyCustomerValues)) {
|
||||
notifyCustomerValues[orderId] = true
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(orderId: string, newStatus: string) {
|
||||
const order = findOrder(orderId)
|
||||
if (!order) return
|
||||
|
||||
const previousStatus = order.status
|
||||
order.status = newStatus
|
||||
statusError.value = ''
|
||||
|
||||
try {
|
||||
const updated = await updateOrderStatus(orderId, newStatus)
|
||||
replaceOrder(updated)
|
||||
} catch (err) {
|
||||
order.status = previousStatus
|
||||
if (!isSessionExpired(err)) {
|
||||
statusError.value =
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: 'Kunde inte uppdatera status. Försök igen.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegisterShipment(orderId: string) {
|
||||
const trackingInput = trackingInputValues[orderId]?.trim()
|
||||
if (!trackingInput) {
|
||||
trackingError.value = 'Ange ett spårnings-ID eller en PostNord-länk.'
|
||||
return
|
||||
}
|
||||
|
||||
const order = findOrder(orderId)
|
||||
if (!order) return
|
||||
|
||||
const previousStatus = order.status
|
||||
const previousTrackingId = order.trackingId
|
||||
const notifyCustomer = notifyCustomerValues[orderId] ?? true
|
||||
trackingError.value = ''
|
||||
registeringId.value = orderId
|
||||
|
||||
try {
|
||||
const updated = await registerShipment(
|
||||
orderId,
|
||||
trackingInput,
|
||||
notifyCustomer,
|
||||
)
|
||||
replaceOrder(updated)
|
||||
trackingInputValues[orderId] = updated.trackingId ?? trackingInput
|
||||
} catch (err) {
|
||||
order.status = previousStatus
|
||||
order.trackingId = previousTrackingId
|
||||
if (!isSessionExpired(err)) {
|
||||
trackingError.value =
|
||||
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
|
||||
}
|
||||
} finally {
|
||||
registeringId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotesSave(orderId: string) {
|
||||
const order = findOrder(orderId)
|
||||
if (!order) return
|
||||
|
||||
const notes = adminNotesValues[orderId]?.trim() || null
|
||||
const previousNotes = order.adminNotes
|
||||
notesError.value = ''
|
||||
savingNotesId.value = orderId
|
||||
|
||||
try {
|
||||
const updated = await updateAdminNotes(orderId, notes)
|
||||
replaceOrder(updated)
|
||||
adminNotesValues[orderId] = updated.adminNotes ?? ''
|
||||
} catch (err) {
|
||||
order.adminNotes = previousNotes
|
||||
adminNotesValues[orderId] = previousNotes ?? ''
|
||||
if (!isSessionExpired(err)) {
|
||||
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
|
||||
}
|
||||
} finally {
|
||||
savingNotesId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
expandedOrderId,
|
||||
statusError,
|
||||
trackingError,
|
||||
notesError,
|
||||
savingNotesId,
|
||||
registeringId,
|
||||
messageModalOrder,
|
||||
trackingInputValues,
|
||||
adminNotesValues,
|
||||
notifyCustomerValues,
|
||||
openMessageModal,
|
||||
closeMessageModal,
|
||||
toggleExpand,
|
||||
handleStatusChange,
|
||||
handleRegisterShipment,
|
||||
handleNotesSave,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { fetchAllOrders, type AdminOrder } from '@/api/admin'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
import { PAID_GROUP_STATUSES } from '@/constants/orderStatus'
|
||||
|
||||
export type AdminOrderFilter =
|
||||
| 'all'
|
||||
| 'processing'
|
||||
| 'paid_group'
|
||||
| 'pending_payment'
|
||||
|
||||
export function useAdminOrders() {
|
||||
const orders = ref<AdminOrder[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const activeFilter = ref<AdminOrderFilter>('all')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = orders.value.length
|
||||
const todo = orders.value.filter((o) => o.status === 'processing').length
|
||||
const paid = orders.value.filter((o) =>
|
||||
PAID_GROUP_STATUSES.includes(
|
||||
o.status as (typeof PAID_GROUP_STATUSES)[number],
|
||||
),
|
||||
).length
|
||||
const pending = orders.value.filter(
|
||||
(o) => o.status === 'pending_payment',
|
||||
).length
|
||||
return { total, todo, paid, pending }
|
||||
})
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
let result = orders.value
|
||||
|
||||
if (activeFilter.value === 'processing') {
|
||||
result = result.filter((o) => o.status === 'processing')
|
||||
} else if (activeFilter.value === 'paid_group') {
|
||||
result = result.filter((o) =>
|
||||
PAID_GROUP_STATUSES.includes(
|
||||
o.status as (typeof PAID_GROUP_STATUSES)[number],
|
||||
),
|
||||
)
|
||||
} else if (activeFilter.value === 'pending_payment') {
|
||||
result = result.filter((o) => o.status === 'pending_payment')
|
||||
}
|
||||
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (query) {
|
||||
result = result.filter(
|
||||
(o) =>
|
||||
o.id.toLowerCase().includes(query) ||
|
||||
o.plate.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
orders.value = await fetchAllOrders()
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function replaceOrder(updated: AdminOrder) {
|
||||
const index = orders.value.findIndex((o) => o.id === updated.id)
|
||||
if (index !== -1) {
|
||||
orders.value[index] = updated
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
orders,
|
||||
loading,
|
||||
error,
|
||||
activeFilter,
|
||||
searchQuery,
|
||||
stats,
|
||||
filteredOrders,
|
||||
loadOrders,
|
||||
replaceOrder,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
/**
|
||||
* useSeo — dynamically updates document <title> and meta tags on route change.
|
||||
*
|
||||
* Usage:
|
||||
* const seo = useSeo()
|
||||
* // On page mount / watch:
|
||||
* watch(route, () => {
|
||||
* seo.set({ title: 'Min sida', description: '...' })
|
||||
* })
|
||||
*
|
||||
* The composable sets <title>, meta[name=description], meta[name=keywords],
|
||||
* meta[property=og:title], meta[property=og:description], meta[name=twitter:title],
|
||||
* and meta[name=twitter:description].
|
||||
*/
|
||||
|
||||
export interface SeoMeta {
|
||||
title: string
|
||||
description: string
|
||||
keywords?: string
|
||||
ogTitle?: string
|
||||
ogDescription?: string
|
||||
canonical?: string
|
||||
}
|
||||
|
||||
export function useSeo() {
|
||||
function set(meta: SeoMeta) {
|
||||
const title = meta.title ?? 'Bilhej'
|
||||
const description =
|
||||
meta.description ??
|
||||
'Skicka brev till fordonsägare via registreringsnummer.'
|
||||
|
||||
// Document title
|
||||
document.title = title
|
||||
|
||||
// Common meta tags
|
||||
updateMeta('description', description)
|
||||
if (meta.keywords) updateMeta('keywords', meta.keywords)
|
||||
|
||||
// Open Graph
|
||||
updateMeta('og:title', meta.ogTitle ?? title, 'property')
|
||||
updateMeta('og:description', meta.ogDescription ?? description, 'property')
|
||||
|
||||
// Twitter
|
||||
updateMeta('twitter:title', meta.ogTitle ?? title, 'name')
|
||||
updateMeta('twitter:description', meta.ogDescription ?? description, 'name')
|
||||
|
||||
// Canonical URL
|
||||
updateCanonical(meta.canonical)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
document.title = 'Bilhej — Skicka brev till fordonsägare'
|
||||
updateMeta(
|
||||
'description',
|
||||
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
|
||||
)
|
||||
updateMeta('og:title', 'Bilhej — Skicka brev till fordonsägare', 'property')
|
||||
updateMeta(
|
||||
'og:description',
|
||||
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
|
||||
'property',
|
||||
)
|
||||
updateCanonical('https://bilhej.se/')
|
||||
}
|
||||
|
||||
return { set, reset }
|
||||
}
|
||||
|
||||
function updateMeta(
|
||||
nameOrProperty: string,
|
||||
content: string,
|
||||
attr: 'name' | 'property' = 'name',
|
||||
) {
|
||||
let el = document.querySelector(
|
||||
`meta[${attr}="${nameOrProperty}"]`,
|
||||
) as HTMLMetaElement | null
|
||||
if (!el) {
|
||||
el = document.createElement('meta')
|
||||
el.setAttribute(attr, nameOrProperty)
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.setAttribute('content', content)
|
||||
}
|
||||
|
||||
function updateCanonical(href: string | undefined) {
|
||||
let link = document.querySelector(
|
||||
'link[rel="canonical"]',
|
||||
) as HTMLLinkElement | null
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.setAttribute('rel', 'canonical')
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.setAttribute('href', href ?? 'https://bilhej.se/')
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
pending_payment: 'Väntar på betalning',
|
||||
paid: 'Betalad',
|
||||
processing: 'Hanteras',
|
||||
sent: 'Skickat',
|
||||
delivered: 'Levererat',
|
||||
failed: 'Misslyckad',
|
||||
cancelled: 'Avbruten',
|
||||
}
|
||||
|
||||
export const ORDER_STATUS_BADGE: Record<string, string> = {
|
||||
pending_payment: 'badge--muted',
|
||||
paid: 'badge--success',
|
||||
processing: 'badge--primary',
|
||||
sent: 'badge--success',
|
||||
delivered: 'badge--success',
|
||||
failed: 'badge--danger',
|
||||
cancelled: 'badge--muted',
|
||||
}
|
||||
|
||||
export const PAID_GROUP_STATUSES = [
|
||||
'processing',
|
||||
'paid',
|
||||
'sent',
|
||||
'delivered',
|
||||
] as const
|
||||
|
||||
export function postNordTrackingUrl(trackingId: string): string {
|
||||
return `https://www.postnord.se/verktyg/spara/?id=${trackingId}`
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
/**
|
||||
* Route-level SEO metadata for Bilhej.
|
||||
* Each entry maps route.name → { title, description, keywords?, canonical? }
|
||||
* These are applied by App.vue on every route change via useSeo().
|
||||
*/
|
||||
export interface RouteSeoMeta {
|
||||
title: string
|
||||
description: string
|
||||
keywords?: string
|
||||
/** The full canonical URL for this page */
|
||||
canonical?: string
|
||||
}
|
||||
|
||||
export const routeSeo: Record<string, RouteSeoMeta> = {
|
||||
home: {
|
||||
title: 'Bilhej — Skicka brev till fordonsägare via registreringsnummer',
|
||||
description:
|
||||
'Skicka ett fysiskt brev till en bilägare med bara registreringsnumret. Välj mall, skriv, betala 49 kr via Swish. Vi postar med spårning.',
|
||||
keywords:
|
||||
'brev till bilägare, kontakta fordonsägare, parkeringsskada, köp bil, skicka brev, registreringsnummer brev',
|
||||
},
|
||||
about: {
|
||||
title: 'Om Bilhej — Skicka fysiska brev till fordonsägare online',
|
||||
description:
|
||||
'Bilhej gör det enkelt att nå en bilägare med ett fysiskt brev. Skriv meddelandet, vi sköter utskick och post. Anonymt eller med dina uppgifter.',
|
||||
keywords: 'om bilhej, hur fungerar bilhej, skicka brev till bilägare',
|
||||
},
|
||||
contact: {
|
||||
title: 'Kontakt — Bilhej support, klagomål och allmänna frågor',
|
||||
description:
|
||||
'Kontakta Bilhej via e-post. Support för beställningar och betalning, allmän kontakt och klagomål.',
|
||||
keywords: 'bilhej kontakt, support bilhej, klagomål',
|
||||
},
|
||||
privacy: {
|
||||
title: 'Integritetspolicy — Bilhej | Personuppgifter och GDPR',
|
||||
description:
|
||||
'Läs om hur Bilhej hanterar personuppgifter: vad vi samlar in, varför, hur länge och dina rättigheter enligt GDPR.',
|
||||
keywords: 'integritetspolicy bilhej, gdpr, personuppgifter brev',
|
||||
},
|
||||
terms: {
|
||||
title: 'Användarvillkor — Bilhej | Villkor för brevtjänsten',
|
||||
description:
|
||||
'Villkor för att använda Bilhej. Regler för brev till fordonsägare, betalning, ansvar och reklamation.',
|
||||
keywords: 'användarvillkor bilhej, köpvillkor, brevtjänst villkor',
|
||||
},
|
||||
register: {
|
||||
title: 'Registrera konto — Bilhej | Skapa konto för beställningar',
|
||||
description:
|
||||
'Skapa ett konto på Bilhej för att följa dina beställningar, se historik och hantera dina brev.',
|
||||
},
|
||||
login: {
|
||||
title: 'Logga in — Bilhej | Dina brev till fordonsägare',
|
||||
description:
|
||||
'Logga in på Bilhej för att se dina beställningar, status och spårning.',
|
||||
},
|
||||
'forgot-password': {
|
||||
title: 'Glömt lösenord — Bilhej | Återställ lösenord',
|
||||
description:
|
||||
'Återställ ditt lösenord på Bilhej. Få en återställningslänk via e-post.',
|
||||
},
|
||||
'guest-checkout': {
|
||||
title: 'Gästbeställning — Bilhej | Skicka brev utan konto',
|
||||
description:
|
||||
'Skicka ett brev till en fordonsägare utan att skapa konto. Ange registreringsnummer, skriv meddelande, betala via Swish.',
|
||||
keywords: 'gästbeställning bilhej, utan konto, snabb beställning',
|
||||
},
|
||||
compose: {
|
||||
title: 'Skriv brev — Bilhej | Komponera meddelande till fordonsägare',
|
||||
description:
|
||||
'Skriv ditt brev till bilägaren. Välj mall för köp, komplimang, parkeringsskada eller tips, eller skriv fritt.',
|
||||
},
|
||||
orders: {
|
||||
title: 'Mina beställningar — Bilhej | Följ dina brev',
|
||||
description:
|
||||
'Se dina tidigare och pågående beställningar. Följ status och spårning för brev du skickat.',
|
||||
},
|
||||
'change-password': {
|
||||
title: 'Byt lösenord — Bilhej | Kontoinställningar',
|
||||
description: 'Byt lösenord på ditt Bilhej-konto.',
|
||||
},
|
||||
'change-email': {
|
||||
title: 'Byt e-postadress — Bilhej | Kontoinställningar',
|
||||
description: 'Byt e-postadress på ditt Bilhej-konto.',
|
||||
},
|
||||
admin: {
|
||||
title: 'Admin — Bilhej | Administrationspanel',
|
||||
description:
|
||||
'Administration av Bilhej — hantera beställningar och användare.',
|
||||
},
|
||||
}
|
||||
22
frontend/src/env.d.ts
vendored
22
frontend/src/env.d.ts
vendored
|
|
@ -1,22 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string
|
||||
readonly VITE_UMAMI_WEBSITE_ID?: string
|
||||
readonly VITE_UMAMI_SCRIPT_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (
|
||||
input?:
|
||||
| string
|
||||
| Record<string, unknown>
|
||||
| ((props: Record<string, unknown>) => Record<string, unknown>),
|
||||
) => void
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,11 @@ import { createApp } from 'vue'
|
|||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { initUmamiAnalytics } from '@/utils/umami'
|
||||
import './assets/styles/base.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
initUmamiAnalytics(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ const highlights = [
|
|||
.about {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(var(--space-xl), 6vw, var(--space-3xl)) var(--page-gutter);
|
||||
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
|
||||
}
|
||||
|
||||
.about__hero {
|
||||
|
|
|
|||
|
|
@ -1,45 +1,111 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useAdminOrders } from '@/composables/useAdminOrders'
|
||||
import { useAdminOrderActions } from '@/composables/useAdminOrderActions'
|
||||
import AdminStatsBar from '@/components/admin/AdminStatsBar.vue'
|
||||
import AdminOrdersTable from '@/components/admin/AdminOrdersTable.vue'
|
||||
import AdminOrderMessageModal from '@/components/admin/AdminOrderMessageModal.vue'
|
||||
import { ref, onMounted, onUnmounted, reactive, computed } from 'vue'
|
||||
import {
|
||||
fetchAllOrders,
|
||||
updateOrderStatus,
|
||||
updateTracking,
|
||||
type AdminOrder,
|
||||
} from '@/api/admin'
|
||||
|
||||
const {
|
||||
orders,
|
||||
loading,
|
||||
error,
|
||||
activeFilter,
|
||||
searchQuery,
|
||||
stats,
|
||||
filteredOrders,
|
||||
loadOrders,
|
||||
replaceOrder,
|
||||
} = useAdminOrders()
|
||||
const orders = ref<AdminOrder[]>([])
|
||||
const expandedOrderId = ref<string | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const statusError = ref('')
|
||||
const trackingError = ref('')
|
||||
const activeFilter = ref<
|
||||
'all' | 'processing' | 'paid_group' | 'pending_payment'
|
||||
>('all')
|
||||
const searchQuery = ref('')
|
||||
const trackingInputValues = reactive<Record<string, string>>({})
|
||||
const messageModalOrder = ref<AdminOrder | null>(null)
|
||||
|
||||
const {
|
||||
expandedOrderId,
|
||||
statusError,
|
||||
trackingError,
|
||||
notesError,
|
||||
savingNotesId,
|
||||
registeringId,
|
||||
messageModalOrder,
|
||||
trackingInputValues,
|
||||
adminNotesValues,
|
||||
notifyCustomerValues,
|
||||
openMessageModal,
|
||||
closeMessageModal,
|
||||
toggleExpand,
|
||||
handleStatusChange,
|
||||
handleRegisterShipment,
|
||||
handleNotesSave,
|
||||
} = useAdminOrderActions(orders, replaceOrder)
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending_payment: 'Väntar på betalning',
|
||||
paid: 'Betalad',
|
||||
processing: 'Hanteras',
|
||||
sent: 'Skickat',
|
||||
delivered: 'Levererat',
|
||||
failed: 'Misslyckad',
|
||||
cancelled: 'Avbruten',
|
||||
}
|
||||
|
||||
const umamiDashboardUrl = import.meta.env.VITE_UMAMI_WEBSITE_ID
|
||||
? 'https://analytics.bilhej.se'
|
||||
: null
|
||||
const statusBadge: Record<string, string> = {
|
||||
pending_payment: 'badge--muted',
|
||||
paid: 'badge--success',
|
||||
processing: 'badge--primary',
|
||||
sent: 'badge--success',
|
||||
delivered: 'badge--success',
|
||||
failed: 'badge--danger',
|
||||
cancelled: 'badge--muted',
|
||||
}
|
||||
|
||||
const allStatuses = [
|
||||
'pending_payment',
|
||||
'paid',
|
||||
'processing',
|
||||
'sent',
|
||||
'delivered',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = orders.value.length
|
||||
const todo = orders.value.filter((o) => o.status === 'processing').length
|
||||
const paid = orders.value.filter((o) =>
|
||||
['paid', 'sent', 'delivered'].includes(o.status),
|
||||
).length
|
||||
const pending = orders.value.filter(
|
||||
(o) => o.status === 'pending_payment',
|
||||
).length
|
||||
return { total, todo, paid, pending }
|
||||
})
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
let result = orders.value
|
||||
|
||||
if (activeFilter.value === 'processing') {
|
||||
result = result.filter((o) => o.status === 'processing')
|
||||
} else if (activeFilter.value === 'paid_group') {
|
||||
result = result.filter((o) =>
|
||||
['paid', 'sent', 'delivered'].includes(o.status),
|
||||
)
|
||||
} else if (activeFilter.value === 'pending_payment') {
|
||||
result = result.filter((o) => o.status === 'pending_payment')
|
||||
}
|
||||
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (query) {
|
||||
result = result.filter(
|
||||
(o) =>
|
||||
o.id.toLowerCase().includes(query) ||
|
||||
o.plate.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function shortOrderId(id: string): string {
|
||||
return id.slice(0, 8)
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function openMessageModal(order: AdminOrder) {
|
||||
messageModalOrder.value = order
|
||||
}
|
||||
|
||||
function closeMessageModal() {
|
||||
messageModalOrder.value = null
|
||||
}
|
||||
|
||||
function handleModalKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && messageModalOrder.value) {
|
||||
|
|
@ -47,9 +113,60 @@ function handleModalKeydown(event: KeyboardEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function toggleExpand(orderId: string) {
|
||||
if (expandedOrderId.value === orderId) {
|
||||
expandedOrderId.value = null
|
||||
} else {
|
||||
expandedOrderId.value = orderId
|
||||
const order = orders.value.find((o) => o.id === orderId)
|
||||
if (order && !(orderId in trackingInputValues)) {
|
||||
trackingInputValues[orderId] = order.trackingId ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(orderId: string, newStatus: string) {
|
||||
const order = orders.value.find((o) => o.id === orderId)
|
||||
if (!order) return
|
||||
|
||||
const previousStatus = order.status
|
||||
order.status = newStatus
|
||||
statusError.value = ''
|
||||
|
||||
try {
|
||||
await updateOrderStatus(orderId, newStatus)
|
||||
} catch {
|
||||
order.status = previousStatus
|
||||
statusError.value = 'Kunde inte uppdatera status. Försök igen.'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrackingSave(orderId: string) {
|
||||
const newTrackingId = trackingInputValues[orderId]?.trim() || null
|
||||
const order = orders.value.find((o) => o.id === orderId)
|
||||
if (!order) return
|
||||
|
||||
const previousTrackingId = order.trackingId
|
||||
order.trackingId = newTrackingId
|
||||
trackingError.value = ''
|
||||
|
||||
try {
|
||||
await updateTracking(orderId, newTrackingId)
|
||||
} catch {
|
||||
order.trackingId = previousTrackingId
|
||||
trackingError.value = 'Kunde inte spara spårnings-ID. Försök igen.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', handleModalKeydown)
|
||||
void loadOrders()
|
||||
try {
|
||||
orders.value = await fetchAllOrders()
|
||||
} catch {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
@ -59,18 +176,7 @@ onUnmounted(() => {
|
|||
|
||||
<template>
|
||||
<div class="admin">
|
||||
<header class="admin__header">
|
||||
<h1 class="admin__title">Administration</h1>
|
||||
<a
|
||||
v-if="umamiDashboardUrl"
|
||||
class="admin__analytics-link"
|
||||
:href="umamiDashboardUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Webbstatistik
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<p
|
||||
v-if="loading"
|
||||
|
|
@ -87,14 +193,57 @@ onUnmounted(() => {
|
|||
</div>
|
||||
|
||||
<template v-else>
|
||||
<AdminStatsBar
|
||||
v-model:active-filter="activeFilter"
|
||||
v-model:search-query="searchQuery"
|
||||
:total="stats.total"
|
||||
:todo="stats.todo"
|
||||
:paid="stats.paid"
|
||||
:pending="stats.pending"
|
||||
<div class="admin__stats">
|
||||
<button
|
||||
type="button"
|
||||
class="admin__stat"
|
||||
:class="{ 'admin__stat--active': activeFilter === 'all' }"
|
||||
@click="activeFilter = 'all'"
|
||||
>
|
||||
<span class="admin__stat-value">{{ stats.total }}</span>
|
||||
<span class="admin__stat-label">Totalt</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin__stat"
|
||||
:class="{ 'admin__stat--active': activeFilter === 'processing' }"
|
||||
@click="activeFilter = 'processing'"
|
||||
>
|
||||
<span class="admin__stat-value">{{ stats.todo }}</span>
|
||||
<span class="admin__stat-label">Att göra</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin__stat"
|
||||
:class="{ 'admin__stat--active': activeFilter === 'paid_group' }"
|
||||
@click="activeFilter = 'paid_group'"
|
||||
>
|
||||
<span class="admin__stat-value">{{ stats.paid }}</span>
|
||||
<span class="admin__stat-label">Betalda</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin__stat"
|
||||
:class="{ 'admin__stat--active': activeFilter === 'pending_payment' }"
|
||||
@click="activeFilter = 'pending_payment'"
|
||||
>
|
||||
<span class="admin__stat-value">{{ stats.pending }}</span>
|
||||
<span class="admin__stat-label">Väntar</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin__toolbar">
|
||||
<label for="admin-order-search" class="admin__search-label"
|
||||
>Sök beställnings-ID eller regnr</label
|
||||
>
|
||||
<input
|
||||
id="admin-order-search"
|
||||
v-model="searchQuery"
|
||||
class="admin__search-input"
|
||||
type="search"
|
||||
placeholder="t.ex. c1eebc99 eller ABC123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="filteredOrders.length === 0"
|
||||
|
|
@ -103,82 +252,232 @@ onUnmounted(() => {
|
|||
Inga beställningar matchar filtret.
|
||||
</p>
|
||||
|
||||
<AdminOrdersTable
|
||||
v-if="filteredOrders.length > 0"
|
||||
:orders="filteredOrders"
|
||||
:expanded-order-id="expandedOrderId"
|
||||
:status-error="statusError"
|
||||
:tracking-error="trackingError"
|
||||
:notes-error="notesError"
|
||||
:tracking-input-values="trackingInputValues"
|
||||
:admin-notes-values="adminNotesValues"
|
||||
:notify-customer-values="notifyCustomerValues"
|
||||
:saving-notes-id="savingNotesId"
|
||||
:registering-id="registeringId"
|
||||
@toggle-expand="toggleExpand"
|
||||
@open-message="openMessageModal"
|
||||
@status-change="handleStatusChange"
|
||||
@register-shipment="handleRegisterShipment"
|
||||
@save-notes="handleNotesSave"
|
||||
@update:tracking-input="
|
||||
(id, value) => {
|
||||
trackingInputValues[id] = value
|
||||
}
|
||||
"
|
||||
@update:admin-notes="
|
||||
(id, value) => {
|
||||
adminNotesValues[id] = value
|
||||
}
|
||||
"
|
||||
@update:notify-customer="
|
||||
(id, value) => {
|
||||
notifyCustomerValues[id] = value
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<p
|
||||
v-if="statusError"
|
||||
class="message message--error admin__status-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ statusError }}
|
||||
</p>
|
||||
|
||||
<AdminOrderMessageModal
|
||||
:order="messageModalOrder"
|
||||
@close="closeMessageModal"
|
||||
<div v-if="filteredOrders.length > 0" class="admin__table-wrap">
|
||||
<table class="admin__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Beställnings-ID</th>
|
||||
<th>E-post</th>
|
||||
<th>Regnr</th>
|
||||
<th>Meddelande</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="order in filteredOrders" :key="order.id">
|
||||
<tr
|
||||
class="admin__row"
|
||||
:class="{
|
||||
'admin__row--expanded': expandedOrderId === order.id,
|
||||
'admin__row--todo': order.status === 'processing',
|
||||
}"
|
||||
>
|
||||
<td>{{ formatDate(order.createdAt) }}</td>
|
||||
<td class="admin__order-id" :title="order.id">
|
||||
{{ shortOrderId(order.id) }}
|
||||
</td>
|
||||
<td>{{ order.email }}</td>
|
||||
<td class="admin__plate">{{ order.plate }}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--ghost btn--sm admin__message-btn"
|
||||
@click.stop="openMessageModal(order)"
|
||||
>
|
||||
Visa meddelande
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
class="admin__status-select"
|
||||
:class="statusBadge[order.status] || 'badge--muted'"
|
||||
:value="order.status"
|
||||
@change="
|
||||
handleStatusChange(
|
||||
order.id,
|
||||
($event.target as HTMLSelectElement).value,
|
||||
)
|
||||
"
|
||||
@click.stop
|
||||
>
|
||||
<option v-for="s in allStatuses" :key="s" :value="s">
|
||||
{{ statusLabels[s] }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="admin__chevron-cell">
|
||||
<button
|
||||
class="admin__expand-btn"
|
||||
: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>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="expandedOrderId === order.id"
|
||||
class="admin__expanded-row"
|
||||
>
|
||||
<td :colspan="7">
|
||||
<div class="admin__expanded-inner">
|
||||
<div class="admin__section">
|
||||
<div class="admin__section-header">
|
||||
<span class="admin__section-label">Spårnings-ID</span>
|
||||
<a
|
||||
v-if="order.trackingId"
|
||||
class="admin__tracking-link"
|
||||
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.stop
|
||||
>
|
||||
Spåra hos PostNord
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="trackingError"
|
||||
class="message message--error admin__tracking-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ trackingError }}
|
||||
</p>
|
||||
|
||||
<div class="admin__tracking-row">
|
||||
<label
|
||||
:for="`tracking-${order.id}`"
|
||||
class="visually-hidden"
|
||||
>Spårnings-ID</label
|
||||
>
|
||||
<input
|
||||
:id="`tracking-${order.id}`"
|
||||
class="admin__tracking-input"
|
||||
type="text"
|
||||
:value="
|
||||
trackingInputValues[order.id] ??
|
||||
order.trackingId ??
|
||||
''
|
||||
"
|
||||
placeholder="PN..."
|
||||
@input="
|
||||
trackingInputValues[order.id] = (
|
||||
$event.target as HTMLInputElement
|
||||
).value
|
||||
"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
class="btn btn--primary btn--sm"
|
||||
@click.stop="handleTrackingSave(order.id)"
|
||||
>
|
||||
Spara
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
<div
|
||||
v-if="messageModalOrder"
|
||||
class="admin-modal-overlay"
|
||||
@click.self="closeMessageModal"
|
||||
>
|
||||
<div
|
||||
class="admin-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-message-modal-title"
|
||||
>
|
||||
<div class="admin-modal__header">
|
||||
<h2 id="admin-message-modal-title" class="admin-modal__title">
|
||||
Brevtext
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-modal__close"
|
||||
aria-label="Stäng"
|
||||
@click="closeMessageModal"
|
||||
>
|
||||
<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>
|
||||
<p class="admin-modal__meta">
|
||||
{{ messageModalOrder.plate }} ·
|
||||
{{ shortOrderId(messageModalOrder.id) }}
|
||||
</p>
|
||||
<div class="admin-modal__body">
|
||||
{{ messageModalOrder.letterText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
max-width: 72rem;
|
||||
margin: var(--space-2xl) auto 0;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.admin__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.admin__title {
|
||||
margin: 0;
|
||||
margin: 0 0 var(--space-xl) 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.admin__analytics-link {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.admin__analytics-link:hover {
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.admin__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
|
@ -274,16 +573,14 @@ onUnmounted(() => {
|
|||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.admin__table {
|
||||
width: 100%;
|
||||
min-width: 60rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
|
@ -292,7 +589,7 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.admin__table th {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.75rem var(--space-md);
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -300,88 +597,15 @@ onUnmounted(() => {
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin__th-expand,
|
||||
.admin__expand-cell {
|
||||
width: 2.75rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.admin__expand-cell {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin__expand-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-border-light);
|
||||
color: var(--color-muted);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin__row:hover .admin__expand-icon {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.admin__row--expanded .admin__expand-icon,
|
||||
.admin__expand-icon--open {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin__row--todo .admin__expand-icon:not(.admin__expand-icon--open) {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.admin__th-date,
|
||||
.admin__td-date {
|
||||
min-width: 6.5rem;
|
||||
}
|
||||
|
||||
.admin__th-id,
|
||||
.admin__order-id {
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.admin__th-email,
|
||||
.admin__email {
|
||||
min-width: 11rem;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.admin__th-plate,
|
||||
.admin__plate {
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.admin__th-message,
|
||||
.admin__message-cell {
|
||||
min-width: 9.5rem;
|
||||
}
|
||||
|
||||
.admin__th-status,
|
||||
.admin__status-cell {
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.admin__row {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin__row:last-child td {
|
||||
.admin__row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
|
|
@ -398,32 +622,14 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.admin__row td {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.75rem var(--space-md);
|
||||
color: var(--color-ink);
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin__email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.admin__plate {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin__message-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin__status-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin__status-select {
|
||||
|
|
@ -443,6 +649,31 @@ onUnmounted(() => {
|
|||
box-shadow: 0 0 0 2px var(--color-primary-ring);
|
||||
}
|
||||
|
||||
.admin__chevron-cell {
|
||||
text-align: center;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.admin__expand-btn {
|
||||
background: none;
|
||||
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__expand-btn:hover {
|
||||
color: var(--color-ink);
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin__expanded-row td {
|
||||
padding: 0;
|
||||
background: var(--color-surface);
|
||||
|
|
@ -471,6 +702,13 @@ onUnmounted(() => {
|
|||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.admin__section-body {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-ink);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.admin__section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -518,48 +756,6 @@ onUnmounted(() => {
|
|||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.admin__checklist {
|
||||
margin: 0 0 var(--space-md);
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-ink-muted);
|
||||
}
|
||||
|
||||
.admin__checklist li + li {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.admin__section-hint {
|
||||
margin: var(--space-xs) 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-ink-muted);
|
||||
}
|
||||
|
||||
.admin__notify {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-ink-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin__notes-input {
|
||||
width: 100%;
|
||||
margin-top: var(--space-sm);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.admin__notes-save {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.admin__tracking-error {
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
|
|
@ -646,14 +842,5 @@ onUnmounted(() => {
|
|||
.admin__stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.admin__table {
|
||||
min-width: 62rem;
|
||||
}
|
||||
|
||||
.admin__message-btn {
|
||||
font-size: 0.75rem;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -124,8 +124,8 @@ async function handleSubmit() {
|
|||
<style scoped>
|
||||
.page {
|
||||
max-width: 28rem;
|
||||
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
|
||||
padding: 0 var(--page-gutter);
|
||||
margin: var(--space-3xl) auto 0;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.page__card {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue