Compare commits

..

No commits in common. "master" and "feature/account-settings-dropdown" have entirely different histories.

102 changed files with 921 additions and 4115 deletions

View file

@ -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 .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.*
!.env.example
**/application-local.yml
# VCS and editor state
.git .git
.gitignore frontend/node_modules
.gitattributes backend/build
.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/src/__tests__ 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

View file

@ -24,12 +24,6 @@ STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_... STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ---------- # ---------- 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 SWISH_NUMBER=0701234567
# ---------- App URL (password reset links in email) ---------- # ---------- 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. # Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
ADMIN_EMAIL=admin@bilhej.se ADMIN_EMAIL=admin@bilhej.se
ADMIN_PASSWORD=change_me_to_a_strong_password 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

View file

@ -4,9 +4,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)' description: 'Git tag to create for this deploy (e.g. v0.1.2) — not the branch/tag above'
required: false required: true
default: 'auto' default: 'v0.1.0'
type: string type: string
jobs: jobs:
@ -21,36 +21,12 @@ jobs:
git fetch --depth 1 origin ${GITHUB_SHA} git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD 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 - name: Tag version
run: | run: |
git tag -d ${{ env.VERSION }} 2>/dev/null || true git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
git tag ${{ env.VERSION }} git tag ${{ github.event.inputs.version }}
git push origin ${{ env.VERSION }} git push origin ${{ github.event.inputs.version }}
- name: Write production .env - name: Write production .env
env: env:
@ -70,7 +46,6 @@ jobs:
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM }} MAIL_FROM: ${{ secrets.MAIL_FROM }}
VITE_UMAMI_WEBSITE_ID: ${{ secrets.VITE_UMAMI_WEBSITE_ID }}
run: | run: |
# Docker Compose treats $ as variable interpolation in .env files. # Docker Compose treats $ as variable interpolation in .env files.
# Escape literal dollar signs (e.g. in passwords) as $$. # Escape literal dollar signs (e.g. in passwords) as $$.
@ -92,7 +67,6 @@ jobs:
printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")" printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")"
printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")" printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")"
printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")" printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")"
printf 'VITE_UMAMI_WEBSITE_ID=%s\n' "$(escape "$VITE_UMAMI_WEBSITE_ID")"
} > .env } > .env
- name: Build and start production stack - name: Build and start production stack
@ -158,7 +132,7 @@ jobs:
run: | run: |
echo "" echo ""
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo " Deployed ${{ env.VERSION }} to production" echo " Deployed ${{ github.event.inputs.version }} to production"
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo "" echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps docker compose -p bilhej-prod -f docker-compose.prod.yml ps

View file

@ -74,14 +74,6 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe
Flyway migrations run automatically on Spring Boot startup. Migration files Flyway migrations run automatically on Spring Boot startup. Migration files
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`. 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`. To reset: `docker compose down -v && docker compose up -d`.
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users, 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 list concrete changes as bullet points. Never write single-line
"feat: add X" messages. "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) ### Frontend (Vue.js 3)
- `<script setup>` with Composition API only. Never Options API. - `<script setup>` with Composition API only. Never Options API.
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables. - 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 entity must NOT have an address field. The address lookup and mailing are
external/human processes in Phase 0. 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) ### 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. `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. - Component tests with Vue Test Utils where needed.
- E2E tests with Playwright in `frontend/e2e/`. - E2E tests with Playwright in `frontend/e2e/`.
### E2E (Playwright) — **Docker only** ### E2E (Playwright)
- `npm run test:e2e` — runs all Playwright tests (headless Chromium).
**Agents and humans: never run Playwright on the host.** - Requires `docker compose up` (backend + frontend running).
- Config: `frontend/playwright.config.ts`.
| Do **not** run | Why | - 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.
| `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`
### CI (future) ### CI (future)
- `./gradlew check` and `npm run test && npm run lint` must pass before merge. - `./gradlew check` and `npm run test && npm run lint` must pass before merge.

View file

@ -342,7 +342,6 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
| `MAIL_USERNAME` | `resend` (literal string) | | `MAIL_USERNAME` | `resend` (literal string) |
| `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) | | `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) |
| `MAIL_FROM` | `noreply@bilhej.se` (must be on verified domain) | | `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. 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 Production does **not** seed `test@bilhej.se` or demo orders. On first start, the

View file

@ -1,12 +1,8 @@
package se.bilhalsning.config; 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.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; 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.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import se.bilhalsning.dto.ErrorResponse;
import se.bilhalsning.security.JwtAuthenticationFilter; import se.bilhalsning.security.JwtAuthenticationFilter;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
@ -22,13 +17,6 @@ import se.bilhalsning.security.JwtService;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { 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 @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@ -58,21 +46,8 @@ public class SecurityConfig {
.requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) .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); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); 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)));
}
} }

View file

@ -9,13 +9,10 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AdminOrderMapper;
import se.bilhalsning.dto.AdminOrderResponse; import se.bilhalsning.dto.AdminOrderResponse;
import se.bilhalsning.dto.RegisterShipmentRequest;
import se.bilhalsning.dto.UpdateAdminNotesRequest;
import se.bilhalsning.dto.UpdateStatusRequest; import se.bilhalsning.dto.UpdateStatusRequest;
import se.bilhalsning.dto.UpdateTrackingRequest;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.service.AdminOrderWorkflowService;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import java.util.List; import java.util.List;
@ -27,12 +24,11 @@ import java.util.UUID;
public class AdminController { public class AdminController {
private final OrderService orderService; private final OrderService orderService;
private final AdminOrderWorkflowService adminOrderWorkflowService;
@GetMapping("/orders") @GetMapping("/orders")
public ResponseEntity<List<AdminOrderResponse>> listAllOrders() { public ResponseEntity<List<AdminOrderResponse>> listAllOrders() {
List<AdminOrderResponse> orders = orderService.getAllOrders().stream() List<AdminOrderResponse> orders = orderService.getAllOrders().stream()
.map(AdminOrderMapper::toResponse) .map(this::toAdminResponse)
.toList(); .toList();
return ResponseEntity.ok(orders); return ResponseEntity.ok(orders);
} }
@ -41,26 +37,29 @@ public class AdminController {
public ResponseEntity<AdminOrderResponse> updateStatus( public ResponseEntity<AdminOrderResponse> updateStatus(
@PathVariable UUID id, @PathVariable UUID id,
@Valid @RequestBody UpdateStatusRequest request) { @Valid @RequestBody UpdateStatusRequest request) {
Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status()); Order order = orderService.updateOrderStatus(id, request.status());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order)); return ResponseEntity.ok(toAdminResponse(order));
} }
@PatchMapping("/orders/{id}/register-shipment") @PatchMapping("/orders/{id}")
public ResponseEntity<AdminOrderResponse> registerShipment( public ResponseEntity<AdminOrderResponse> updateTracking(
@PathVariable UUID id, @PathVariable UUID id,
@Valid @RequestBody RegisterShipmentRequest request) { @Valid @RequestBody UpdateTrackingRequest request) {
Order order = adminOrderWorkflowService.registerShipment( Order order = orderService.updateTracking(id, request.trackingId());
id, return ResponseEntity.ok(toAdminResponse(order));
request.trackingInput(),
request.notifyCustomerOrDefault());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
} }
@PatchMapping("/orders/{id}/notes") private AdminOrderResponse toAdminResponse(Order order) {
public ResponseEntity<AdminOrderResponse> updateNotes( String email = order.getUser() != null ? order.getUser().getEmail() : "";
@PathVariable UUID id, return new AdminOrderResponse(
@RequestBody UpdateAdminNotesRequest request) { order.getId(),
Order order = adminOrderWorkflowService.updateAdminNotes(id, request.adminNotes()); email,
return ResponseEntity.ok(AdminOrderMapper.toResponse(order)); order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt()
);
} }
} }

View file

@ -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));
}
}

View file

@ -2,7 +2,6 @@ package se.bilhalsning.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public record AdminOrderResponse( public record AdminOrderResponse(
@ -13,9 +12,5 @@ public record AdminOrderResponse(
String status, String status,
String trackingId, String trackingId,
BigDecimal amountPaid, BigDecimal amountPaid,
Instant shippedAt, Instant createdAt
String adminNotes,
Instant createdAt,
List<String> allowedStatuses,
boolean canRegisterShipment
) {} ) {}

View file

@ -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;
}
}

View file

@ -1,5 +0,0 @@
package se.bilhalsning.dto;
public record UpdateAdminNotesRequest(
String adminNotes
) {}

View file

@ -6,7 +6,7 @@ import jakarta.validation.constraints.Pattern;
public record UpdateStatusRequest( public record UpdateStatusRequest(
@NotBlank(message = "Status krävs") @NotBlank(message = "Status krävs")
@Pattern( @Pattern(
regexp = "pending_payment|paid|processing|sent|delivered|failed|cancelled", regexp = "pending_payment|paid|processing|sent|delivered|failed",
message = "Ogiltig status" message = "Ogiltig status"
) )
String status String status

View file

@ -0,0 +1,5 @@
package se.bilhalsning.dto;
public record UpdateTrackingRequest(
String trackingId
) {}

View file

@ -43,12 +43,6 @@ public class Order {
@Column(name = "tracking_id", length = 100) @Column(name = "tracking_id", length = 100)
private String trackingId; 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) @Column(name = "created_at", nullable = false)
private Instant createdAt; private Instant createdAt;
@ -136,22 +130,6 @@ public class Order {
this.trackingId = trackingId; 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() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }

View file

@ -7,7 +7,6 @@ import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.OrderStatus;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
@ -18,7 +17,4 @@ public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = {"user"}) @EntityGraph(attributePaths = {"user"})
List<Order> findAllByOrderByCreatedAtDesc(); List<Order> findAllByOrderByCreatedAtDesc();
@EntityGraph(attributePaths = {"user"})
Optional<Order> findWithUserById(UUID id);
} }

View file

@ -18,7 +18,7 @@ public class JwtService {
this(secret, DEFAULT_EXPIRATION_MS); 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.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
this.expirationMs = expirationMs; this.expirationMs = expirationMs;
} }

View file

@ -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();
}
}

View file

@ -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");
}
}
}

View file

@ -93,71 +93,4 @@ public class EmailService {
throw new IllegalStateException("Kunde inte skicka e-post just nu"); 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 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");
}
}
} }

View file

@ -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";
}
}

View file

@ -16,7 +16,6 @@ import java.util.UUID;
public class OrderService { public class OrderService {
private final OrderRepository orderRepository; private final OrderRepository orderRepository;
private final OrderNotificationService orderNotificationService;
public Order createOrder(UUID userId, String plate, String letterText) { public Order createOrder(UUID userId, String plate, String letterText) {
Order order = new Order(); Order order = new Order();
@ -40,12 +39,27 @@ public class OrderService {
return orderRepository.findAllByOrderByCreatedAtDesc(); 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) { public Order confirmPayment(UUID orderId, UUID userId) {
Order order = requirePendingOwnedBy(orderId, userId); Order order = requirePendingOwnedBy(orderId, userId);
order.setStatus(OrderStatus.PROCESSING); order.setStatus(OrderStatus.PROCESSING);
Order saved = orderRepository.save(order); return orderRepository.save(order);
orderNotificationService.notifyOrderProcessing(saved);
return saved;
} }
public Order cancelOrder(UUID orderId, UUID userId) { public Order cancelOrder(UUID orderId, UUID userId) {

View file

@ -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;
}
}

View file

@ -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'
);

View file

@ -1,3 +0,0 @@
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE orders ADD COLUMN admin_notes TEXT;

View file

@ -1,6 +1,6 @@
package se.bilhalsning.controller; 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.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; 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.get;
@ -23,9 +23,7 @@ import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.AdminOrderWorkflowService;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
@SpringBootTest @SpringBootTest
@ -38,22 +36,17 @@ class AdminControllerTest {
@MockitoBean @MockitoBean
private OrderService orderService; private OrderService orderService;
@MockitoBean
private AdminOrderWorkflowService adminOrderWorkflowService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@WithMockUser(username = "test@bilhej.se", roles = "USER") @WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception { void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -68,96 +61,151 @@ class AdminControllerTest {
.andExpect(jsonPath("$[0].id").value(order.getId().toString())) .andExpect(jsonPath("$[0].id").value(order.getId().toString()))
.andExpect(jsonPath("$[0].email").value("test@bilhej.se")) .andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
.andExpect(jsonPath("$[0].plate").value("ABC123")) .andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].status").value("sent")) .andExpect(jsonPath("$[0].letterText").value("Test letter"))
.andExpect(jsonPath("$[0].allowedStatuses").isArray()) .andExpect(jsonPath("$[0].status").value("sent"));
.andExpect(jsonPath("$[0].canRegisterShipment").value(true)); }
@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 @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateOrderStatusSuccessfully() throws Exception { void shouldUpdateOrderStatusSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); 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"))) when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order);
.thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId) mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"failed\"}")) .content("{\"status\":\"paid\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("failed")); .andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("paid"));
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn409WhenStatusTransitionInvalid() throws Exception { void shouldReturn400WhenStatusIsInvalid() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); mockMvc.perform(patch("/api/admin/orders/{id}/status",
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("delivered"))) "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"delivered\"}")) .content("{\"status\":\"invalid_status\"}"))
.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\":\"\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateAdminNotes() throws Exception { void shouldReturn400WhenStatusIsBlank() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); mockMvc.perform(patch("/api/admin/orders/{id}/status",
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING); "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
order.setAdminNotes("Kontaktat TS");
when(adminOrderWorkflowService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"adminNotes\":\"Kontaktat TS\"}")) .content("{\"status\":\"\"}"))
.andExpect(status().isOk()) .andExpect(status().isBadRequest());
.andExpect(jsonPath("$.adminNotes").value("Kontaktat TS"));
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception { void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); 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)); .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) .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()); .andExpect(status().isNotFound());
} }
@ -171,6 +219,7 @@ class AdminControllerTest {
order.setPlate(plate); order.setPlate(plate);
order.setLetterText("Test letter"); order.setLetterText("Test letter");
order.setStatus(status); order.setStatus(status);
order.setTrackingId(null);
order.setAmountPaid(new BigDecimal("49.00")); order.setAmountPaid(new BigDecimal("49.00"));
return order; return order;

View file

@ -225,8 +225,7 @@ class AuthControllerTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content( .content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}")) "{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -264,7 +263,6 @@ class AuthControllerTest {
mockMvc.perform(post("/api/auth/change-email") mockMvc.perform(post("/api/auth/change-email")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}")) .content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
} }

View file

@ -18,22 +18,16 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.OrderResponse; import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class OrderControllerTest { class OrderControllerTest {
private static final String TEST_SECRET =
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@ -44,10 +38,9 @@ class OrderControllerTest {
private UserService userService; private UserService userService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders")) mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -107,31 +100,11 @@ class OrderControllerTest {
} }
@Test @Test
void shouldReturn401WhenPostingWithoutAuth() throws Exception { void shouldReturn403WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders") mockMvc.perform(post("/api/orders")
.contentType("application/json") .contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}")) .content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.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")));
} }
@Test @Test

View file

@ -39,11 +39,10 @@ class PaymentControllerTest {
private UserService userService; private UserService userService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(post("/api/payment/{orderId}/pay", mockMvc.perform(post("/api/payment/{orderId}/pay",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")) "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test

View file

@ -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;
}
}

View file

@ -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));
}
}

View file

@ -27,9 +27,6 @@ class OrderServiceTest {
@Mock @Mock
private OrderRepository orderRepository; private OrderRepository orderRepository;
@Mock
private OrderNotificationService orderNotificationService;
@InjectMocks @InjectMocks
private OrderService orderService; private OrderService orderService;
@ -252,5 +249,4 @@ class OrderServiceTest {
assertThrows(OrderNotFoundException.class, assertThrows(OrderNotFoundException.class,
() -> orderService.confirmPayment(orderId, otherUserId)); () -> orderService.confirmPayment(orderId, otherUserId));
} }
} }

View file

@ -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(" "));
}
}

View file

@ -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:

View file

@ -89,8 +89,8 @@ services:
sleep 1; sleep 1;
done; done;
echo 'Waiting for backend...'; echo 'Waiting for backend...';
for i in \$(seq 1 120); do for i in \$(seq 1 60); do
curl -sf http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break; curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
sleep 1; sleep 1;
done; done;
echo 'Waiting for frontend...'; echo 'Waiting for frontend...';

View file

@ -49,9 +49,6 @@ services:
build: build:
dockerfile: docker/frontend.prod.Dockerfile dockerfile: docker/frontend.prod.Dockerfile
context: . 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 container_name: bilhej-frontend-prod
ports: ports:
- "3001:80" - "3001:80"

View file

@ -1,15 +1,3 @@
FROM eclipse-temurin:21-jdk FROM eclipse-temurin:21-jdk
WORKDIR /app 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"] ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"]

View file

@ -1,16 +1,7 @@
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app
# Install dependencies first so this layer caches independently of source changes.
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm install 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/ . COPY frontend/ .
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View file

@ -1,9 +1,5 @@
FROM node:24-alpine AS builder FROM node:24-alpine AS builder
WORKDIR /app 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 ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
COPY frontend/ . COPY frontend/ .

View file

@ -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).

View file

@ -1,5 +1,9 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test' 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.describe('Account settings', () => {
test('can change password and change back', async ({ page, request }) => { 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.', 'Vi har skickat en bekräftelselänk till din nya e-postadress.',
), ),
).toBeVisible() ).toBeVisible()
expect(await countMessagesTo(request, tempEmail)).toBe(1)
expect(await countMessagesTo(request, originalEmail)).toBe(0)
const token = await waitForEmailChangeToken(request, tempEmail, { const token = await waitForEmailChangeToken(request, tempEmail, {
publicBaseUrl: 'http://frontend', publicBaseUrl: 'http://frontend',
@ -64,6 +70,7 @@ test.describe('Account settings', () => {
'Vi har skickat en bekräftelselänk till din nya e-postadress.', 'Vi har skickat en bekräftelselänk till din nya e-postadress.',
), ),
).toBeVisible() ).toBeVisible()
expect(await countMessagesTo(request, originalEmail)).toBe(1)
const restoreToken = await waitForEmailChangeToken(request, originalEmail, { const restoreToken = await waitForEmailChangeToken(request, originalEmail, {
publicBaseUrl: 'http://frontend', publicBaseUrl: 'http://frontend',

View file

@ -1,19 +1,15 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { loginAsAdmin } from './helpers/admin'
const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
const SEEDED_ORDER_SHORT_ID = SEEDED_ORDER_ID.slice(0, 8) 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.describe('Admin dashboard', () => {
test.beforeEach(async ({ page }) => { 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 }) => { test('admin can navigate to admin page', async ({ page }) => {
@ -41,7 +37,9 @@ test.describe('Admin dashboard', () => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.getByRole('columnheader', { name: 'Datum' })).toBeVisible() 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: 'E-post' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Regnr' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Regnr' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
@ -71,37 +69,34 @@ test.describe('Admin dashboard', () => {
await expect(dialog).not.toBeVisible() 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 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 page.goto('/admin')
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
const row = rowByPlate(page, PROCESSING_PLATE) const expandBtns = page.locator('.admin__expand-btn')
await row.click() await expandBtns.first().click()
await expect(page.locator('.admin__tracking-input').first()).toBeVisible() 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() await expect(page.locator('.admin__tracking-input').first()).not.toBeVisible()
}) })
test('status dropdown shows current status for sent orders', async ({ test('status dropdown changes update order status', async ({ page }) => {
page,
}) => {
await page.goto('/admin') 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 selects = page.locator('.admin__status-select')
const select = row.locator('.admin__status-select') await selects.first().selectOption('delivered')
await expect(select).toBeVisible()
await expect(select).toHaveValue('sent') const updatedSelect = selects.first()
await expect(updatedSelect).toHaveValue('delivered')
}) })
test('admin cannot access admin page without auth', async ({ page }) => { 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 }) => { test('expanded row shows tracking input and save button', async ({ page }) => {
await page.goto('/admin') 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.locator('.admin__tracking-input')).toBeVisible()
await expect( await expect(page.getByRole('button', { name: 'Spara' })).toBeVisible()
page.getByRole('button', { name: 'Registrera utskick' }),
).toBeVisible()
}) })
test('shows PostNord link when trackingId exists', async ({ page }) => { test('shows PostNord link when trackingId exists', async ({ page }) => {
await page.goto('/admin') 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') const trackingLink = page.locator('.admin__tracking-link')
await expect(trackingLink).toBeVisible() await expect(trackingLink).toBeVisible()
@ -138,7 +132,8 @@ test.describe('Admin dashboard', () => {
await page.goto('/admin') await page.goto('/admin')
const defRow = page.locator('.admin__row', { hasText: 'DEF456' }).first() 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') const trackingLink = page.locator('.admin__tracking-link')
await expect(trackingLink).not.toBeVisible() await expect(trackingLink).not.toBeVisible()

View file

@ -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')
})
})

View file

@ -70,13 +70,6 @@ test.describe('Auth guards', () => {
}) })
test('allows admin user to access /admin', async ({ page }) => { 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' }) const jwt = makeJwt({ role: 'admin' })
await page.goto('/') await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)

View file

@ -1,19 +1,14 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
test.describe.configure({ mode: 'serial' }) test.describe.configure({ mode: 'serial' })
let plateCounter = 0
function uniquePlate(prefix: string): string { function uniquePlate(prefix: string): string {
plateCounter += 1 const digits = String((Date.now() % 90) + 10)
const digits = String(10 + (plateCounter % 90)) return `${prefix}${digits}E`
const letter = String.fromCharCode(65 + (plateCounter % 26))
return `${prefix}${digits}${letter}`
} }
test.describe('Deferred payment and admin lookup', () => { test.describe('Deferred payment and admin lookup', () => {
let plate = '' const plate = uniquePlate('LAT')
const letterText = 'E2E-test: betalar senare från orderhistoriken.' const letterText = 'E2E-test: betalar senare från orderhistoriken.'
let orderId = '' let orderId = ''
@ -40,26 +35,9 @@ test.describe('Deferred payment and admin lookup', () => {
await page.getByRole('button', { name: 'Ja, jag har betalat' }).click() 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 ({ test('user creates order, leaves payment, and pays later from orders', async ({
page, page,
}) => { }) => {
plate = uniquePlate('LAT')
await loginAsTestUser(page) await loginAsTestUser(page)
await page.goto(`/compose?plate=${plate}`) 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() 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, page,
}) => { }) => {
await loginAsAdmin(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 }) const row = page.locator('.admin__row', { hasText: shortOrderId })
await expect(row).toBeVisible({ timeout: 15_000 }) await expect(row).toBeVisible()
await expect(row).toHaveClass(/admin__row--todo/)
await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId) await expect(row.locator('.admin__order-id')).toHaveText(shortOrderId)
const plateInAdmin = (await row.locator('.admin__plate').textContent())?.trim() await expect(row.locator('.admin__plate')).toHaveText(plate)
expect(plateInAdmin).toBeTruthy() await expect(row).toHaveClass(/admin__row--todo/)
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(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 ({ 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 page.evaluate(() => localStorage.clear())
await loginAsAdmin(page) 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 }) const unpaidRow = page.locator('.admin__row', { hasText: unpaidShortId })
await expect(unpaidRow).not.toBeVisible() await expect(unpaidRow).not.toBeVisible()
await page.getByRole('button', { name: /Väntar/ }).click() await page.getByRole('button', { name: /Väntar/ }).click()
await expect(page.locator('.admin__stat--active')).toContainText('Väntar') await page.locator('#admin-order-search').fill(unpaidPlate)
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 expect(unpaidRow).toBeVisible() await expect(unpaidRow).toBeVisible()
await expect(unpaidRow.locator('.admin__plate')).toHaveText(plateInAdmin!) await expect(unpaidRow.locator('.admin__plate')).toHaveText(unpaidPlate)
}) })
}) })

View file

@ -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}`
}

View file

@ -100,13 +100,6 @@ test.describe('Header auth state', () => {
}) })
test('logout redirects to home page', async ({ page }) => { 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' }) const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/orders') await page.goto('/orders')
await page.evaluate( 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) { async function authenticateUser(page: import('@playwright/test').Page) {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' }) const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/') await page.goto('/')

View file

@ -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 })
}

View file

@ -49,7 +49,7 @@ test.describe('Order history', () => {
await page.goto('/orders') 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('Väntar på betalning').first()).toBeVisible()
await expect(page.getByText('Levererat').first()).toBeVisible() await expect(page.getByText('Levererat').first()).toBeVisible()
}) })

View file

@ -49,31 +49,6 @@ test.describe('Payment redirect', () => {
await page.waitForURL(/\/betalning\//) await page.waitForURL(/\/betalning\//)
await expect(page.getByText('Swisha till')).toBeVisible() await expect(page.getByText('Swisha till')).toBeVisible()
await expect( await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
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')
}) })
}) })

View file

@ -9,7 +9,6 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
@ -17,7 +16,6 @@
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
@ -793,6 +791,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -810,6 +811,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -827,6 +831,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -844,6 +851,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -861,6 +871,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -878,6 +891,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1038,16 +1054,6 @@
"undici-types": "~7.16.0" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1", "version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
@ -1956,15 +1962,6 @@
"node": ">=8" "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": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -1990,91 +1987,11 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -2087,6 +2004,7 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "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": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -2251,12 +2160,6 @@
"node": ">=8" "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": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "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": "^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": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@ -2969,6 +2863,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -3390,6 +3285,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3411,6 +3309,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3432,6 +3333,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3453,6 +3357,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3836,15 +3743,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "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", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -4037,15 +3936,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "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": { "node_modules/postcss": {
"version": "8.5.13", "version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
@ -4144,23 +4034,6 @@
"node": ">=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": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -4211,15 +4084,6 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -4230,12 +4094,6 @@
"node": ">=0.10.0" "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": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@ -4350,12 +4208,6 @@
"node": ">=10" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5242,12 +5094,6 @@
"node": ">= 8" "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -5390,12 +5236,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/yaml": {
"version": "2.8.3", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
@ -5411,134 +5251,6 @@
"url": "https://github.com/sponsors/eemeli" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -17,7 +17,6 @@
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
@ -25,7 +24,6 @@
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",

View file

@ -23,27 +23,6 @@ export default defineConfig({
projects: [ projects: [
{ {
name: 'chromium', 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' }, use: { browserName: 'chromium' },
}, },
], ],

View file

@ -43,11 +43,7 @@ const mockOrders = [
status: 'sent', status: 'sent',
trackingId: 'PN123456789', trackingId: 'PN123456789',
amountPaid: 49.0, amountPaid: 49.0,
shippedAt: '2026-05-13T12:00:00Z',
adminNotes: null,
createdAt: '2026-05-11T12:00:00Z', createdAt: '2026-05-11T12:00:00Z',
allowedStatuses: ['sent', 'delivered', 'failed'],
canRegisterShipment: true,
}, },
{ {
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
@ -57,11 +53,7 @@ const mockOrders = [
status: 'processing', status: 'processing',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: null,
shippedAt: null,
adminNotes: null,
createdAt: '2026-05-14T13:00:00Z', createdAt: '2026-05-14T13:00:00Z',
allowedStatuses: ['processing', 'failed'],
canRegisterShipment: true,
}, },
{ {
id: 'c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', id: 'c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13',
@ -71,24 +63,16 @@ const mockOrders = [
status: 'pending_payment', status: 'pending_payment',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: null,
shippedAt: null,
adminNotes: null,
createdAt: '2026-05-15T14:00:00Z', createdAt: '2026-05-15T14:00:00Z',
allowedStatuses: ['pending_payment', 'failed'],
canRegisterShipment: false,
}, },
] ]
function freshMockOrders() {
return mockOrders.map((order) => ({ ...order }))
}
describe('AdminDashboard', () => { describe('AdminDashboard', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
globalThis.fetch = vi.fn() globalThis.fetch = vi.fn()
vi.mocked(globalThis.fetch).mockResolvedValue( vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, freshMockOrders()), mockFetchResponse(200, mockOrders),
) )
}) })
@ -117,10 +101,10 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Datum') 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('E-post')
expect(wrapper.text()).toContain('Regnr') expect(wrapper.text()).toContain('Regnr')
expect(wrapper.text()).toContain('Brev') expect(wrapper.text()).toContain('Meddelande')
expect(wrapper.text()).toContain('Status') expect(wrapper.text()).toContain('Status')
}) })
@ -179,7 +163,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) 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 expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.find('.admin__expanded-row').exists()).toBe(true) expect(wrapper.find('.admin__expanded-row').exists()).toBe(true)
@ -193,7 +177,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) 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 expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.findAll('.admin__expanded-row')).toHaveLength(1) expect(wrapper.findAll('.admin__expanded-row')).toHaveLength(1)
@ -214,16 +198,15 @@ describe('AdminDashboard', () => {
it('fires status update API on dropdown change', async () => { it('fires status update API on dropdown change', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) .mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce( .mockResolvedValueOnce(
mockFetchResponse(200, { ...mockOrders[0], status: 'delivered' }), mockFetchResponse(200, { ...mockOrders[0], status: 'paid' }),
) )
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin__status-select') const selects = wrapper.findAll('.admin__status-select')
await selects[0].setValue('delivered')
await selects[0].trigger('change') await selects[0].trigger('change')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -231,14 +214,14 @@ describe('AdminDashboard', () => {
'/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status', '/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status',
expect.objectContaining({ expect.objectContaining({
method: 'PATCH', method: 'PATCH',
body: '{"status":"delivered"}', body: '{"status":"sent"}',
}), }),
) )
}) })
it('shows status error on failed update', async () => { it('shows status error on failed update', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) .mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce( .mockResolvedValueOnce(
mockFetchResponse(500, { message: 'Server error' }), mockFetchResponse(500, { message: 'Server error' }),
) )
@ -247,11 +230,10 @@ describe('AdminDashboard', () => {
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin__status-select') const selects = wrapper.findAll('.admin__status-select')
await selects[0].setValue('delivered')
await selects[0].trigger('change') await selects[0].trigger('change')
await new Promise((r) => setTimeout(r, 50)) 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 () => { it('formats dates in Swedish locale', async () => {
@ -265,7 +247,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) 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 expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -278,7 +260,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) 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 expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -292,7 +274,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) 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 expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -300,47 +282,32 @@ describe('AdminDashboard', () => {
expect(link.exists()).toBe(false) expect(link.exists()).toBe(false)
}) })
it('fires register-shipment API on register button click', async () => { it('fires PATCH on tracking save button click', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch).mockResolvedValueOnce(
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) mockFetchResponse(200, mockOrders),
.mockResolvedValueOnce(
mockFetchResponse(200, {
...mockOrders[1],
status: 'sent',
trackingId: 'PN999',
}),
) )
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) 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 expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.admin__tracking-input').setValue('PN999') await wrapper.find('.btn--primary').trigger('click')
const registerBtn = wrapper
.findAll('button')
.find((btn) => btn.text() === 'Registrera utskick')
expect(registerBtn).toBeDefined()
await registerBtn!.trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/register-shipment', '/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
expect.objectContaining({ expect.objectContaining({
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({
trackingInput: 'PN999',
notifyCustomer: true,
}),
}), }),
) )
}) })
it('shows tracking error on failed save', async () => { it('shows tracking error on failed save', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders())) .mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce( .mockResolvedValueOnce(
mockFetchResponse(500, { message: 'Server error' }), mockFetchResponse(500, { message: 'Server error' }),
) )
@ -348,18 +315,14 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) 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 expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.admin__tracking-input').setValue('PN999') await wrapper.find('.btn--primary').trigger('click')
const registerBtn = wrapper
.findAll('button')
.find((btn) => btn.text() === 'Registrera utskick')
await registerBtn!.trigger('click')
await new Promise((r) => setTimeout(r, 50)) 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 () => { it('shows Att göra stat for processing orders', async () => {
@ -429,10 +392,7 @@ describe('AdminDashboard', () => {
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin__row') const rows = wrapper.findAll('.admin__row')
const processingRow = rows.find( const processingRow = rows.find((row) => row.text().includes('XYZ789'))
(row) =>
row.text().includes('XYZ789') && row.classes().includes('admin__row'),
)
expect(processingRow?.classes()).toContain('admin__row--todo') expect(processingRow?.classes()).toContain('admin__row--todo')
}) })
}) })

View file

@ -105,7 +105,7 @@ describe('AppHeader', () => {
const wrapper = mount(AppHeader, { const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] }, 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', () => { it('does not show user email', () => {
@ -178,7 +178,7 @@ describe('AppHeader', () => {
it('shows settings menu with account links', async () => { it('shows settings menu with account links', async () => {
const { wrapper } = mountAuthenticated() 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') await wrapper.find('.app-header__settings-trigger').trigger('click')
@ -190,26 +190,15 @@ describe('AppHeader', () => {
expect(links[1].text()).toBe('Byt lösenord') 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 () => { it('highlights settings trigger on change password page', async () => {
const { wrapper, router } = mountAuthenticated() const { wrapper, router } = mountAuthenticated()
await router.push('/andra-losenord') await router.push('/andra-losenord')
await router.isReady() await router.isReady()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain( expect(
'app-header__settings-trigger--active', wrapper.find('.app-header__settings-trigger').classes(),
) ).toContain('app-header__settings-trigger--active')
}) })
it('highlights settings trigger on change email page', async () => { it('highlights settings trigger on change email page', async () => {
@ -218,9 +207,9 @@ describe('AppHeader', () => {
await router.isReady() await router.isReady()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('.app-header__settings-trigger').classes()).toContain( expect(
'app-header__settings-trigger--active', wrapper.find('.app-header__settings-trigger').classes(),
) ).toContain('app-header__settings-trigger--active')
}) })
it('does not highlight settings trigger on other pages', async () => { it('does not highlight settings trigger on other pages', async () => {

View file

@ -15,10 +15,7 @@ describe('ChangeEmailPage', () => {
it('renders current email and form fields', () => { it('renders current email and form fields', () => {
const pinia = createPinia() const pinia = createPinia()
setActivePinia(pinia) setActivePinia(pinia)
localStorage.setItem( localStorage.setItem('auth_token', makeJwt({ sub: 'test@bilhej.se', role: 'user' }))
'auth_token',
makeJwt({ sub: 'test@bilhej.se', role: 'user' }),
)
const router = createRouter({ const router = createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),
@ -38,10 +35,7 @@ describe('ChangeEmailPage', () => {
it('shows auth email from store', () => { it('shows auth email from store', () => {
const pinia = createPinia() const pinia = createPinia()
setActivePinia(pinia) setActivePinia(pinia)
localStorage.setItem( localStorage.setItem('auth_token', makeJwt({ sub: 'user@example.com', role: 'user' }))
'auth_token',
makeJwt({ sub: 'user@example.com', role: 'user' }),
)
const router = createRouter({ const router = createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),

View file

@ -29,8 +29,6 @@ describe('ContactPage', () => {
const link = wrapper.find('a[href="mailto:support@bilhej.se"]') const link = wrapper.find('a[href="mailto:support@bilhej.se"]')
expect(link.exists()).toBe(true) expect(link.exists()).toBe(true)
expect(link.text()).toBe('support@bilhej.se') expect(link.text()).toBe('support@bilhej.se')
expect(link.attributes('aria-label')).toBe( expect(link.attributes('aria-label')).toBe('Skicka till support: support@bilhej.se')
'Skicka till support: support@bilhej.se',
)
}) })
}) })

View file

@ -4,26 +4,6 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import OrdersPage from '@/pages/OrdersPage.vue' 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) { function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({ return Promise.resolve({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
@ -396,34 +376,3 @@ describe('OrdersPage', () => {
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false) 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' },
})
})
})

View file

@ -5,26 +5,14 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import PaymentRedirect from '@/pages/PaymentRedirect.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import OrdersPage from '@/pages/OrdersPage.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', () => ({ vi.mock('@/api/payment', () => ({
payOrder: vi.fn(), payOrder: vi.fn(),
fetchSwishInfo: 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 { payOrder, fetchSwishInfo } from '@/api/payment'
import QRCode from 'qrcode'
const mockPayOrder = vi.mocked(payOrder) const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo) const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
const mockToDataURL = vi.mocked(QRCode.toDataURL)
function createTestRouter() { function createTestRouter() {
return createRouter({ return createRouter({
@ -71,7 +59,6 @@ describe('PaymentRedirect', () => {
number: '0701234567', number: '0701234567',
amount: 49, amount: 49,
}) })
mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
}) })
it('renders heading and amount', async () => { it('renders heading and amount', async () => {
@ -94,7 +81,7 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain('Beställnings-ID')
expect(wrapper.text()).toContain(orderId) expect(wrapper.text()).toContain(orderId)
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain(
'fylls i automatiskt via QR-kod eller länk', 'Ange beställnings-ID ovan som meddelande i Swish-appen',
) )
}) })
}) })
@ -106,30 +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)
})
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 () => { it('shows confirmation dialog after clicking pay button', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { 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(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat') expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567') expect(wrapper.text()).toContain('0701234567')
@ -140,15 +110,15 @@ describe('PaymentRedirect', () => {
it('can cancel confirmation dialog', async () => { it('can cancel confirmation dialog', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { 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(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbryt') expect(wrapper.text()).toContain('Avbryt')
}) })
await wrapper.find('.payment__confirm-cancel').trigger('click') await wrapper.find('.btn--ghost').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Swisha till') expect(wrapper.text()).toContain('Swisha till')
expect(wrapper.text()).not.toContain('Avbryt') expect(wrapper.text()).not.toContain('Avbryt')
@ -167,15 +137,16 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { 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(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') 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') expect(mockPayOrder).toHaveBeenCalledWith('order-1')
}) })
@ -185,15 +156,16 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { 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(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') 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(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen') expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
@ -212,15 +184,16 @@ describe('PaymentRedirect', () => {
const { wrapper, router } = await mountPage() const { wrapper, router } = await mountPage()
await vi.waitFor(() => { 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(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') 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(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders') expect(router.currentRoute.value.name).toBe('orders')

View file

@ -41,16 +41,6 @@ describe('PrivacyPolicyPage', () => {
expect(wrapper.text()).toContain('varken vi eller obehöriga') 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', () => { it('links to contact email and contact page', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mount(PrivacyPolicyPage, { const wrapper = mount(PrivacyPolicyPage, {

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import router, { scrollBehavior } from '@/router' import router from '@/router'
describe('Router', () => { describe('Router', () => {
beforeEach(() => { beforeEach(() => {
@ -8,25 +8,6 @@ describe('Router', () => {
localStorage.clear() 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 () => { it('resolves / to HomePage', async () => {
await router.push('/') await router.push('/')
await router.isReady() 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 { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -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 { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -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)
})
})

View file

@ -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' })
})
})

View file

@ -8,11 +8,7 @@ export interface AdminOrder {
status: string status: string
trackingId: string | null trackingId: string | null
amountPaid: number | null amountPaid: number | null
shippedAt: string | null
adminNotes: string | null
createdAt: string createdAt: string
allowedStatuses: string[]
canRegisterShipment: boolean
} }
export function fetchAllOrders(): Promise<AdminOrder[]> { export function fetchAllOrders(): Promise<AdminOrder[]> {
@ -29,23 +25,12 @@ export function updateOrderStatus(
}) })
} }
export function registerShipment( export function updateTracking(
orderId: string, orderId: string,
trackingInput: string, trackingId: string | null,
notifyCustomer = true,
): Promise<AdminOrder> { ): Promise<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}/register-shipment`, { return request<AdminOrder>(`/admin/orders/${orderId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ trackingInput, notifyCustomer }), body: JSON.stringify({ trackingId }),
})
}
export function updateAdminNotes(
orderId: string,
adminNotes: string | null,
): Promise<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}/notes`, {
method: 'PATCH',
body: JSON.stringify({ adminNotes }),
}) })
} }

View file

@ -1,6 +1,3 @@
import { useAuthStore } from '@/stores/authStore'
import router from '@/router'
const API_BASE = import.meta.env.VITE_API_URL || '/api' const API_BASE = import.meta.env.VITE_API_URL || '/api'
export class ApiError extends Error { 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 { function getToken(): string | null {
return localStorage.getItem('auth_token') 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>( export async function request<T>(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
@ -54,9 +34,6 @@ export async function request<T>(
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && !url.startsWith('/auth/')) {
handleExpiredSession()
}
const body = await response.json().catch(() => ({})) const body = await response.json().catch(() => ({}))
throw new ApiError(response.status, body.message || 'Något gick fel') throw new ApiError(response.status, body.message || 'Något gick fel')
} }

View file

@ -15,44 +15,3 @@ export function payOrder(orderId: string): Promise<Order> {
export function fetchSwishInfo(): Promise<SwishInfo> { export function fetchSwishInfo(): Promise<SwishInfo> {
return request<SwishInfo>('/payment/swish-info') 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
*/
function normalizeSwishNumber(number: string): string {
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
}

View file

@ -94,10 +94,6 @@ a {
/* transitions */ /* transitions */
--transition-fast: 150ms ease; --transition-fast: 150ms ease;
--transition-base: 200ms ease; --transition-base: 200ms ease;
/* layout */
--page-gutter: var(--space-lg);
--header-height: 3.25rem;
} }
/* ── Body ────────────────────────────────────────────────────────────── */ /* ── Body ────────────────────────────────────────────────────────────── */
@ -411,34 +407,3 @@ a[href]:hover {
.text-xs { .text-xs {
font-size: 0.75rem; 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;
}
}

View file

@ -32,7 +32,7 @@ import { RouterLink } from 'vue-router'
.app-footer__inner { .app-footer__inner {
max-width: 72rem; max-width: 72rem;
margin: 0 auto; margin: 0 auto;
padding: var(--space-xl) var(--page-gutter); padding: var(--space-xl) var(--space-lg);
text-align: center; text-align: center;
} }
@ -66,15 +66,4 @@ import { RouterLink } from 'vue-router'
font-size: 0.75rem; font-size: 0.75rem;
margin: 0; 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> </style>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { RouterLink, useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
@ -7,10 +7,10 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const isSettingsActive = computed( const isSettingsActive = computed(
() => route.name === 'change-email' || route.name === 'change-password', () =>
route.name === 'change-email' || route.name === 'change-password',
) )
const settingsOpen = ref(false) const settingsOpen = ref(false)
const menuOpen = ref(false)
const settingsRef = ref<HTMLElement | null>(null) const settingsRef = ref<HTMLElement | null>(null)
function toggleSettings() { function toggleSettings() {
@ -21,67 +21,30 @@ function closeSettings() {
settingsOpen.value = false settingsOpen.value = false
} }
function toggleMenu() {
menuOpen.value = !menuOpen.value
if (!menuOpen.value) {
closeSettings()
}
}
function closeMenu() {
menuOpen.value = false
closeSettings()
}
function handleDocumentClick(event: MouseEvent) { function handleDocumentClick(event: MouseEvent) {
if (!settingsRef.value?.contains(event.target as Node)) { if (!settingsRef.value?.contains(event.target as Node)) {
settingsOpen.value = false settingsOpen.value = false
} }
} }
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu()
}
}
function handleNavClick() {
closeMenu()
}
function handleLogout() { function handleLogout() {
closeMenu()
auth.logout() auth.logout()
router.push('/') router.push('/')
} }
watch(
() => route.fullPath,
() => {
closeMenu()
},
)
watch(menuOpen, (open) => {
document.body.classList.toggle('nav-menu-open', open)
})
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleDocumentClick) document.addEventListener('click', handleDocumentClick)
document.addEventListener('keydown', handleKeydown)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick) document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('keydown', handleKeydown)
document.body.classList.remove('nav-menu-open')
}) })
</script> </script>
<template> <template>
<header class="app-header" :class="{ 'app-header--menu-open': menuOpen }"> <header class="app-header">
<div class="app-header__inner"> <div class="app-header__inner">
<RouterLink to="/" class="app-header__logo" @click="handleNavClick"> <RouterLink to="/" class="app-header__logo">
<svg <svg
class="app-header__logo-icon" class="app-header__logo-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -108,62 +71,13 @@ onUnmounted(() => {
</svg> </svg>
Bilhej Bilhej
</RouterLink> </RouterLink>
<nav class="app-header__nav">
<button <RouterLink to="/" class="app-header__link">Hem</RouterLink>
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
>
<template v-if="!auth.isAuthenticated"> <template v-if="!auth.isAuthenticated">
<RouterLink <RouterLink to="/logga-in" class="app-header__link"
to="/logga-in"
class="app-header__link"
@click="handleNavClick"
>Logga in</RouterLink >Logga in</RouterLink
> >
<RouterLink <RouterLink to="/registrera" class="app-header__link"
to="/registrera"
class="app-header__link"
@click="handleNavClick"
>Registrera</RouterLink >Registrera</RouterLink
> >
</template> </template>
@ -171,38 +85,12 @@ onUnmounted(() => {
<RouterLink <RouterLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin" to="/admin"
class="app-header__link" class="app-header__link app-header__link--admin"
@click="handleNavClick"
>Admin</RouterLink >Admin</RouterLink
> >
<RouterLink <RouterLink to="/orders" class="app-header__link"
to="/orders"
class="app-header__link"
@click="handleNavClick"
>Mina beställningar</RouterLink >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"> <div ref="settingsRef" class="app-header__settings">
<button <button
type="button" type="button"
@ -277,10 +165,9 @@ onUnmounted(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--space-md);
max-width: 72rem; max-width: 72rem;
margin: 0 auto; margin: 0 auto;
padding: 0.875rem var(--page-gutter); padding: 0.875rem var(--space-lg);
} }
.app-header__logo { .app-header__logo {
@ -291,7 +178,6 @@ onUnmounted(() => {
font-weight: 700; font-weight: 700;
color: var(--color-ink); color: var(--color-ink);
text-decoration: none; text-decoration: none;
flex-shrink: 0;
} }
.app-header__logo-icon { .app-header__logo-icon {
@ -299,26 +185,6 @@ onUnmounted(() => {
height: 1.5rem; 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 { .app-header__nav {
display: flex; display: flex;
align-items: center; align-items: center;
@ -343,23 +209,21 @@ onUnmounted(() => {
background: var(--color-primary-soft); background: var(--color-primary-soft);
} }
.app-header__link--active-settings { .app-header__link--admin {
color: var(--color-primary-dark);
background: var(--color-primary-soft); background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 600;
} }
.app-header__link--settings-mobile { .app-header__link--admin:hover {
display: none; background: #e9d5ff;
color: var(--color-primary-dark);
} }
.app-header__email { .app-header__email {
color: var(--color-muted); color: var(--color-muted);
font-size: 0.8125rem; font-size: 0.8125rem;
padding: 0 0.5rem; padding: 0 0.5rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.app-header__settings { .app-header__settings {
@ -449,69 +313,4 @@ onUnmounted(() => {
border-color: var(--color-danger); border-color: var(--color-danger);
background: var(--color-danger-soft); 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> </style>

View file

@ -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 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 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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}`
}

22
frontend/src/env.d.ts vendored
View file

@ -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
}
}

View file

@ -2,13 +2,11 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { initUmamiAnalytics } from '@/utils/umami'
import './assets/styles/base.css' import './assets/styles/base.css'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
initUmamiAnalytics(router)
app.mount('#app') app.mount('#app')

View file

@ -82,7 +82,7 @@ const highlights = [
.about { .about {
max-width: 48rem; max-width: 48rem;
margin: 0 auto; 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 { .about__hero {

View file

@ -1,45 +1,111 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, reactive, computed } from 'vue'
import { useAdminOrders } from '@/composables/useAdminOrders' import {
import { useAdminOrderActions } from '@/composables/useAdminOrderActions' fetchAllOrders,
import AdminStatsBar from '@/components/admin/AdminStatsBar.vue' updateOrderStatus,
import AdminOrdersTable from '@/components/admin/AdminOrdersTable.vue' updateTracking,
import AdminOrderMessageModal from '@/components/admin/AdminOrderMessageModal.vue' type AdminOrder,
} from '@/api/admin'
const { const orders = ref<AdminOrder[]>([])
orders, const expandedOrderId = ref<string | null>(null)
loading, const loading = ref(true)
error, const error = ref('')
activeFilter, const statusError = ref('')
searchQuery, const trackingError = ref('')
stats, const activeFilter = ref<
filteredOrders, 'all' | 'processing' | 'paid_group' | 'pending_payment'
loadOrders, >('all')
replaceOrder, const searchQuery = ref('')
} = useAdminOrders() const trackingInputValues = reactive<Record<string, string>>({})
const messageModalOrder = ref<AdminOrder | null>(null)
const { const statusLabels: Record<string, string> = {
expandedOrderId, pending_payment: 'Väntar på betalning',
statusError, paid: 'Betalad',
trackingError, processing: 'Hanteras',
notesError, sent: 'Skickat',
savingNotesId, delivered: 'Levererat',
registeringId, failed: 'Misslyckad',
messageModalOrder, cancelled: 'Avbruten',
trackingInputValues, }
adminNotesValues,
notifyCustomerValues,
openMessageModal,
closeMessageModal,
toggleExpand,
handleStatusChange,
handleRegisterShipment,
handleNotesSave,
} = useAdminOrderActions(orders, replaceOrder)
const umamiDashboardUrl = import.meta.env.VITE_UMAMI_WEBSITE_ID const statusBadge: Record<string, string> = {
? 'https://analytics.bilhej.se' pending_payment: 'badge--muted',
: null 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) { function handleModalKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && messageModalOrder.value) { 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) 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(() => { onUnmounted(() => {
@ -59,18 +176,7 @@ onUnmounted(() => {
<template> <template>
<div class="admin"> <div class="admin">
<header class="admin__header">
<h1 class="admin__title">Administration</h1> <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 <p
v-if="loading" v-if="loading"
@ -87,14 +193,57 @@ onUnmounted(() => {
</div> </div>
<template v-else> <template v-else>
<AdminStatsBar <div class="admin__stats">
v-model:active-filter="activeFilter" <button
v-model:search-query="searchQuery" type="button"
:total="stats.total" class="admin__stat"
:todo="stats.todo" :class="{ 'admin__stat--active': activeFilter === 'all' }"
:paid="stats.paid" @click="activeFilter = 'all'"
:pending="stats.pending" >
<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 <p
v-if="filteredOrders.length === 0" v-if="filteredOrders.length === 0"
@ -103,82 +252,232 @@ onUnmounted(() => {
Inga beställningar matchar filtret. Inga beställningar matchar filtret.
</p> </p>
<AdminOrdersTable <p
v-if="filteredOrders.length > 0" v-if="statusError"
:orders="filteredOrders" class="message message--error admin__status-error"
:expanded-order-id="expandedOrderId" role="alert"
:status-error="statusError" >
:tracking-error="trackingError" {{ statusError }}
:notes-error="notesError" </p>
:tracking-input-values="trackingInputValues"
:admin-notes-values="adminNotesValues" <div v-if="filteredOrders.length > 0" class="admin__table-wrap">
:notify-customer-values="notifyCustomerValues" <table class="admin__table">
:saving-notes-id="savingNotesId" <thead>
:registering-id="registeringId" <tr>
@toggle-expand="toggleExpand" <th>Datum</th>
@open-message="openMessageModal" <th>Beställnings-ID</th>
@status-change="handleStatusChange" <th>E-post</th>
@register-shipment="handleRegisterShipment" <th>Regnr</th>
@save-notes="handleNotesSave" <th>Meddelande</th>
@update:tracking-input=" <th>Status</th>
(id, value) => { <th></th>
trackingInputValues[id] = value </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,
)
" "
@update:admin-notes=" @click.stop
(id, value) => { >
adminNotesValues[id] = value <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'
" "
@update:notify-customer=" @click.stop="toggleExpand(order.id)"
(id, value) => { >
notifyCustomerValues[id] = value <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> </template>
<AdminOrderMessageModal <div
:order="messageModalOrder" v-if="messageModalOrder"
@close="closeMessageModal" 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> </div>
</template> </template>
<style> <style scoped>
.admin { .admin {
max-width: 72rem; max-width: 72rem;
margin: var(--space-2xl) auto 0; margin: var(--space-2xl) auto 0;
padding: 0 var(--space-lg); 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 { .admin__title {
margin: 0; margin: 0 0 var(--space-xl) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: var(--color-ink); 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 { .admin__stats {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@ -274,16 +573,14 @@ onUnmounted(() => {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch;
} }
.admin__table { .admin__table {
width: 100%; width: 100%;
min-width: 60rem; border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -292,7 +589,7 @@ onUnmounted(() => {
} }
.admin__table th { .admin__table th {
padding: 0.75rem 1rem; padding: 0.75rem var(--space-md);
text-align: left; text-align: left;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
@ -300,88 +597,15 @@ onUnmounted(() => {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border); 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 { .admin__row {
cursor: pointer; cursor: pointer;
border-bottom: 1px solid var(--color-border-light);
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
.admin__row:last-child td { .admin__row:last-child {
border-bottom: none; border-bottom: none;
} }
@ -398,32 +622,14 @@ onUnmounted(() => {
} }
.admin__row td { .admin__row td {
padding: 0.75rem 1rem; padding: 0.75rem var(--space-md);
color: var(--color-ink); 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; white-space: nowrap;
font-size: 0.8125rem;
color: var(--color-muted);
} }
.admin__plate { .admin__plate {
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
white-space: nowrap;
}
.admin__message-cell {
text-align: center;
}
.admin__status-cell {
white-space: nowrap;
} }
.admin__status-select { .admin__status-select {
@ -443,6 +649,31 @@ onUnmounted(() => {
box-shadow: 0 0 0 2px var(--color-primary-ring); 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 { .admin__expanded-row td {
padding: 0; padding: 0;
background: var(--color-surface); background: var(--color-surface);
@ -471,6 +702,13 @@ onUnmounted(() => {
margin-bottom: var(--space-sm); 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 { .admin__section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -518,48 +756,6 @@ onUnmounted(() => {
margin-bottom: var(--space-md); 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 { .admin__tracking-error {
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
@ -646,14 +842,5 @@ onUnmounted(() => {
.admin__stats { .admin__stats {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.admin__table {
min-width: 62rem;
}
.admin__message-btn {
font-size: 0.75rem;
padding-inline: 0.5rem;
}
} }
</style> </style>

View file

@ -124,8 +124,8 @@ async function handleSubmit() {
<style scoped> <style scoped>
.page { .page {
max-width: 28rem; max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0; margin: var(--space-3xl) auto 0;
padding: 0 var(--page-gutter); padding: 0 var(--space-lg);
} }
.page__card { .page__card {

View file

@ -148,8 +148,8 @@ async function handleSubmit() {
<style scoped> <style scoped>
.page { .page {
max-width: 28rem; max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0; margin: var(--space-3xl) auto 0;
padding: 0 var(--page-gutter); padding: 0 var(--space-lg);
} }
.page__card { .page__card {

View file

@ -2,7 +2,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders' import { createOrder } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
@ -42,10 +41,8 @@ async function handleSubmit() {
params: { orderId: order.id }, params: { orderId: order.id },
query: { plate: plate.value }, query: { plate: plate.value },
}) })
} catch (err) { } catch {
if (!isSessionExpired(err)) {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.' errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }
@ -285,10 +282,8 @@ async function handleSubmit() {
text-decoration: underline; text-decoration: underline;
} }
@media (max-width: 639px) { @media (max-width: 768px) {
.compose__layout { .compose__layout {
margin-top: var(--space-xl);
padding-inline: var(--page-gutter);
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View file

@ -38,8 +38,7 @@ async function handleSubmit() {
} else if (err instanceof ApiError) { } else if (err instanceof ApiError) {
errorMessage.value = err.message errorMessage.value = err.message
} else { } else {
errorMessage.value = errorMessage.value = 'Något gick fel. Begär en ny bekräftelselänk från inställningar.'
'Något gick fel. Begär en ny bekräftelselänk från inställningar.'
} }
} finally { } finally {
submitting.value = false submitting.value = false
@ -107,8 +106,8 @@ async function handleSubmit() {
<style scoped> <style scoped>
.page { .page {
max-width: 28rem; max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0; margin: var(--space-3xl) auto 0;
padding: 0 var(--page-gutter); padding: 0 var(--space-lg);
} }
.page__card { .page__card {

View file

@ -86,7 +86,7 @@ const contactChannels = [
.contact { .contact {
max-width: 48rem; max-width: 48rem;
margin: 0 auto; 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);
} }
.contact__hero { .contact__hero {
@ -197,9 +197,7 @@ const contactChannels = [
background: var(--color-primary-soft); background: var(--color-primary-soft);
border: 1px solid #bfdbfe; border: 1px solid #bfdbfe;
border-radius: var(--radius-md); border-radius: var(--radius-md);
transition: transition: background var(--transition-fast), border-color var(--transition-fast);
background var(--transition-fast),
border-color var(--transition-fast);
} }
.contact__mailto:hover { .contact__mailto:hover {

View file

@ -2,7 +2,6 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { fetchOrder, updateOrder, type Order } from '@/api/orders' import { fetchOrder, updateOrder, type Order } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
@ -45,10 +44,8 @@ async function loadOrder() {
if (fetched.status === 'pending_payment') { if (fetched.status === 'pending_payment') {
letterText.value = fetched.letterText letterText.value = fetched.letterText
} }
} catch (err) { } catch {
if (!isSessionExpired(err)) {
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.' loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -67,10 +64,8 @@ async function handleSubmit() {
params: { orderId: order.value.id }, params: { orderId: order.value.id },
query: { plate: order.value.plate }, query: { plate: order.value.plate },
}) })
} catch (err) { } catch {
if (!isSessionExpired(err)) {
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.' errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }
@ -332,10 +327,8 @@ onMounted(loadOrder)
text-decoration: underline; text-decoration: underline;
} }
@media (max-width: 639px) { @media (max-width: 768px) {
.compose__layout { .compose__layout {
margin-top: var(--space-xl);
padding-inline: var(--page-gutter);
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View file

@ -87,8 +87,8 @@ async function handleSubmit() {
<style scoped> <style scoped>
.page { .page {
max-width: 28rem; max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0; margin: var(--space-3xl) auto 0;
padding: 0 var(--page-gutter); padding: 0 var(--space-lg);
} }
.page__card { .page__card {

View file

@ -1140,11 +1140,11 @@ async function handleLookup(lookedUpPlate: string) {
line-height: 1.65; line-height: 1.65;
} }
@media (max-width: 639px) { @media (max-width: 900px) {
.home__hero { .home__hero {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-xl); gap: var(--space-xl);
padding: var(--space-xl) var(--page-gutter); padding: var(--space-xl) var(--space-lg);
margin-top: 0; margin-top: 0;
border-radius: 0; border-radius: 0;
border-left: none; border-left: none;
@ -1168,15 +1168,5 @@ async function handleLookup(lookedUpPlate: string) {
.home__use--wide .home__use-icon { .home__use--wide .home__use-icon {
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
.home__uses,
.home__steps,
.home__trust {
padding-inline: var(--page-gutter);
}
.home__trust-inner {
padding: var(--space-lg);
}
} }
</style> </style>

View file

@ -103,8 +103,8 @@ async function handleSubmit() {
<style scoped> <style scoped>
.page { .page {
max-width: 28rem; max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0; margin: var(--space-3xl) auto 0;
padding: 0 var(--page-gutter); padding: 0 var(--space-lg);
} }
.page__card { .page__card {

View file

@ -2,12 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { fetchOrders, cancelOrder, type Order } from '@/api/orders' import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
import { fetchSwishInfo } from '@/api/payment' import { fetchSwishInfo } from '@/api/payment'
import { isSessionExpired } from '@/api/client'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import {
ORDER_STATUS_BADGE,
ORDER_STATUS_LABELS,
} from '@/constants/orderStatus'
const ORDER_AMOUNT_FALLBACK = 49 const ORDER_AMOUNT_FALLBACK = 49
@ -47,6 +42,26 @@ const completedOrders = computed(() =>
orders.value.filter((order) => order.status !== 'pending_payment'), orders.value.filter((order) => order.status !== 'pending_payment'),
) )
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
processing: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
cancelled: 'Avbruten',
}
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',
}
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', { return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric', year: 'numeric',
@ -66,10 +81,8 @@ async function loadOrders() {
]) ])
orders.value = fetchedOrders orders.value = fetchedOrders
orderAmount.value = swishInfo.amount orderAmount.value = swishInfo.amount
} catch (err) { } catch {
if (!isSessionExpired(err)) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.' error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -90,11 +103,8 @@ async function handleCancel(order: Order) {
try { try {
const updated = await cancelOrder(order.id) const updated = await cancelOrder(order.id)
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o)) orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
} catch (err) { } catch {
if (!isSessionExpired(err)) { actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
actionError.value =
'Kunde inte avbryta beställningen. Försök igen senare.'
}
} finally { } finally {
cancellingId.value = null cancellingId.value = null
} }
@ -156,7 +166,7 @@ onMounted(loadOrders)
<span class="orders__plate-value">{{ order.plate }}</span> <span class="orders__plate-value">{{ order.plate }}</span>
</p> </p>
<span class="badge badge--warning"> <span class="badge badge--warning">
{{ ORDER_STATUS_LABELS[order.status] }} {{ statusLabels[order.status] }}
</span> </span>
</div> </div>
@ -252,9 +262,9 @@ onMounted(loadOrders)
</p> </p>
<span <span
class="badge" class="badge"
:class="ORDER_STATUS_BADGE[order.status] || 'badge--muted'" :class="statusBadge[order.status] || 'badge--muted'"
> >
{{ ORDER_STATUS_LABELS[order.status] || order.status }} {{ statusLabels[order.status] || order.status }}
</span> </span>
</div> </div>
@ -306,8 +316,8 @@ onMounted(loadOrders)
<style scoped> <style scoped>
.page { .page {
max-width: 48rem; max-width: 48rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0; margin: var(--space-3xl) auto 0;
padding: 0 var(--page-gutter); padding: 0 var(--space-lg);
} }
.page__title { .page__title {
@ -594,33 +604,4 @@ onMounted(loadOrders)
.orders__loading { .orders__loading {
padding: var(--space-2xl) 0; padding: var(--space-2xl) 0;
} }
@media (max-width: 639px) {
.orders__card-head {
flex-direction: column;
align-items: flex-start;
}
.orders__plate-badge {
max-width: 100%;
}
.orders__links {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.orders__link-sep {
display: none;
}
.orders__text-link {
padding: 0.5rem 0;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
</style> </style>

View file

@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import QRCode from 'qrcode' import { payOrder, fetchSwishInfo } from '@/api/payment'
import { payOrder, fetchSwishInfo, buildSwishPaymentUrl } from '@/api/payment'
import { isSessionExpired } from '@/api/client'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -14,27 +12,12 @@ const swishAmount = ref(49)
const paying = ref(false) const paying = ref(false)
const error = ref('') const error = ref('')
const showConfirmation = ref(false) const showConfirmation = ref(false)
const qrDataUrl = ref('')
const swishPaymentUrl = computed(() =>
swishNumber.value
? buildSwishPaymentUrl(swishNumber.value, swishAmount.value, orderId)
: '',
)
onMounted(async () => { onMounted(async () => {
try { try {
const info = await fetchSwishInfo() const info = await fetchSwishInfo()
swishNumber.value = info.number swishNumber.value = info.number
swishAmount.value = info.amount swishAmount.value = info.amount
if (swishPaymentUrl.value) {
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
width: 224,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
})
}
} catch { } catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.' error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
} }
@ -55,10 +38,8 @@ async function confirmPayment() {
try { try {
await payOrder(orderId) await payOrder(orderId)
await router.push({ name: 'orders' }) await router.push({ name: 'orders' })
} catch (err) { } catch {
if (!isSessionExpired(err)) {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.' error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
}
} finally { } finally {
paying.value = false paying.value = false
} }
@ -94,37 +75,21 @@ async function confirmPayment() {
</div> </div>
<template v-if="!showConfirmation"> <template v-if="!showConfirmation">
<!-- QR code scan with the Swish app (desktop users) -->
<div v-if="qrDataUrl" class="payment__qr">
<img :src="qrDataUrl" alt="Swish QR-kod" class="payment__qr-img" />
<p class="payment__qr-hint">
Skanna QR-koden med Swish-appen för att betala
</p>
</div>
<!-- Direct link opens the Swish app (mobile users) -->
<a
v-if="swishPaymentUrl"
:href="swishPaymentUrl"
class="btn btn--primary btn--lg payment__swish-link"
>
Betala med Swish
</a>
<!-- Manual fallback -->
<div class="payment__swish"> <div class="payment__swish">
<p class="payment__swish-label">Swisha till</p> <p class="payment__swish-label">Swisha till</p>
<p class="payment__swish-number">{{ swishNumber }}</p> <p class="payment__swish-number">{{ swishNumber }}</p>
<p class="payment__swish-instruction"> <p class="payment__swish-instruction">
Belopp och beställnings-ID fylls i automatiskt via QR-kod eller Ange beställnings-ID ovan som meddelande i Swish-appen.
länk.
</p> </p>
<p class="payment__swish-instruction"> <p class="payment__swish-instruction">
Betala manuellt om du inte har Swish-appen tillgänglig. Tryck sedan knappen nedan för att bekräfta.
</p> </p>
</div> </div>
<button class="btn btn--ghost payment__submit" @click="startPayment"> <button
class="btn btn--primary btn--lg payment__submit"
@click="startPayment"
>
Jag har betalat Jag har betalat
</button> </button>
</template> </template>
@ -160,8 +125,8 @@ async function confirmPayment() {
<style scoped> <style scoped>
.page { .page {
max-width: 28rem; max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0; margin: var(--space-3xl) auto 0;
padding: 0 var(--page-gutter); padding: 0 var(--space-lg);
} }
.page__card { .page__card {
@ -233,31 +198,6 @@ async function confirmPayment() {
color: var(--color-ink); color: var(--color-ink);
} }
.payment__qr {
text-align: center;
margin-bottom: var(--space-lg);
}
.payment__qr-img {
width: 224px;
height: 224px;
border-radius: var(--radius-md);
margin: 0 auto var(--space-sm);
}
.payment__qr-hint {
font-size: 0.8125rem;
color: var(--color-muted);
}
.payment__swish-link {
display: block;
width: 100%;
text-align: center;
text-decoration: none;
margin-bottom: var(--space-lg);
}
.payment__swish { .payment__swish {
background: var(--color-border-light); background: var(--color-border-light);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);

View file

@ -40,15 +40,6 @@ const sections = [
'Vi säljer inte personuppgifter och visar inte mottagarens identitet eller adress för dig som avsändare.', 'Vi säljer inte personuppgifter och visar inte mottagarens identitet eller adress för dig som avsändare.',
], ],
}, },
{
id: 'webbanalys',
title: 'Webbstatistik',
paragraphs: [
'Vi använder självhostad webbanalys (Umami) på analytics.bilhej.se för att förstå hur webbplatsen används, till exempel vilka sidor som besöks och ungefärlig geografisk fördelning på landsnivå.',
'Analysen bygger på sidvisningar och teknisk information som webbläsaren skickar vid besök. Vi lagrar inte besökares IP-adresser i Bilhejs databas; Umami behandlar IP tillfälligt för att kunna visa land och tar inte emot personuppgifter som du skriver i brev eller konto.',
'Du kan begränsa spårning med webbläsarens spärrlistor eller “Do Not Track”. Kontakta oss om du har frågor om webbanalys.',
],
},
{ {
id: 'lagring', id: 'lagring',
title: 'Hur länge sparar vi uppgifterna?', title: 'Hur länge sparar vi uppgifterna?',
@ -93,8 +84,8 @@ const sections = [
<p class="policy__eyebrow">Integritet</p> <p class="policy__eyebrow">Integritet</p>
<h1 class="policy__title">Integritetspolicy</h1> <h1 class="policy__title">Integritetspolicy</h1>
<p class="policy__lead"> <p class="policy__lead">
Här beskriver vi hur Bilhej behandlar personuppgifter när du skickar Här beskriver vi hur Bilhej behandlar personuppgifter när du skickar brev
brev via tjänsten, och vilka rättigheter du har. via tjänsten, och vilka rättigheter du har.
</p> </p>
<p class="policy__updated">Senast uppdaterad: 22 maj 2026</p> <p class="policy__updated">Senast uppdaterad: 22 maj 2026</p>
</section> </section>
@ -122,8 +113,7 @@ const sections = [
>kontakt@bilhej.se</a >kontakt@bilhej.se</a
> >
eller vår eller vår
<RouterLink to="/kontakt" class="policy__link">kontaktsida</RouterLink <RouterLink to="/kontakt" class="policy__link">kontaktsida</RouterLink>.
>.
</p> </p>
</div> </div>
</section> </section>
@ -134,7 +124,7 @@ const sections = [
.policy { .policy {
max-width: 48rem; max-width: 48rem;
margin: 0 auto; 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);
} }
.policy__hero { .policy__hero {

View file

@ -165,8 +165,8 @@ async function handleSubmit() {
<style scoped> <style scoped>
.page { .page {
max-width: 28rem; max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0; margin: var(--space-3xl) auto 0;
padding: 0 var(--page-gutter); padding: 0 var(--space-lg);
} }
.page__card { .page__card {

View file

@ -149,8 +149,8 @@ async function handleSubmit() {
<style scoped> <style scoped>
.page { .page {
max-width: 28rem; max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0; margin: var(--space-3xl) auto 0;
padding: 0 var(--page-gutter); padding: 0 var(--space-lg);
} }
.page__card { .page__card {

View file

@ -132,8 +132,7 @@ const sections = [
>support@bilhej.se</a >support@bilhej.se</a
> >
eller vår eller vår
<RouterLink to="/kontakt" class="terms__link">kontaktsida</RouterLink <RouterLink to="/kontakt" class="terms__link">kontaktsida</RouterLink>.
>.
</p> </p>
</div> </div>
</section> </section>
@ -144,7 +143,7 @@ const sections = [
.terms { .terms {
max-width: 48rem; max-width: 48rem;
margin: 0 auto; 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);
} }
.terms__hero { .terms__hero {

View file

@ -1,8 +1,4 @@
import { import { createRouter, createWebHistory } from 'vue-router'
createRouter,
createWebHistory,
type RouteLocationNormalized,
} from 'vue-router'
import HomePage from '@/pages/HomePage.vue' import HomePage from '@/pages/HomePage.vue'
import ComposePage from '@/pages/ComposePage.vue' import ComposePage from '@/pages/ComposePage.vue'
import AboutPage from '@/pages/AboutPage.vue' import AboutPage from '@/pages/AboutPage.vue'
@ -23,23 +19,8 @@ import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { getActivePinia } from 'pinia' import { getActivePinia } from 'pinia'
export function scrollBehavior(
to: RouteLocationNormalized,
_from: RouteLocationNormalized,
savedPosition: { left: number; top: number } | null,
) {
if (savedPosition) {
return savedPosition
}
if (to.hash) {
return { el: to.hash, top: 0, behavior: 'smooth' as const }
}
return { top: 0, left: 0 }
}
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior,
routes: [ routes: [
{ {
path: '/', path: '/',
@ -148,11 +129,9 @@ router.beforeEach((to) => {
if (!getActivePinia()) return if (!getActivePinia()) return
const auth = useAuthStore() const auth = useAuthStore()
const authenticated = auth.isAuthenticated && !auth.isTokenExpired()
if (to.meta.guestOnly && authenticated) return { name: 'home' } if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' }
if (to.meta.requiresAuth && !authenticated) { if (to.meta.requiresAuth && !auth.isAuthenticated) {
if (auth.isAuthenticated) auth.logout()
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' } if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }

View file

@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { register, login, changeEmail, confirmEmailChange } from '@/api/auth' import { register, login, changeEmail, confirmEmailChange } from '@/api/auth'
import { parseJwtPayload, isTokenExpired as isJwtExpired } from '@/utils/jwt' import { parseJwtPayload } from '@/utils/jwt'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token')) const token = ref<string | null>(localStorage.getItem('auth_token'))
@ -21,10 +21,6 @@ export const useAuthStore = defineStore('auth', () => {
return payload.role ?? null return payload.role ?? null
} }
function isTokenExpired(): boolean {
return isJwtExpired(token.value)
}
function setToken(newToken: string) { function setToken(newToken: string) {
token.value = newToken token.value = newToken
role.value = extractRole(newToken) role.value = extractRole(newToken)
@ -73,7 +69,6 @@ export const useAuthStore = defineStore('auth', () => {
email, email,
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
isTokenExpired,
registerUser, registerUser,
loginUser, loginUser,
changeUserEmail, changeUserEmail,

View file

@ -20,10 +20,3 @@ export function parseJwtPayload(token: string): JwtPayload {
return {} return {}
} }
} }
export function isTokenExpired(token: string | null): boolean {
if (!token) return true
const payload = parseJwtPayload(token)
if (payload.exp === undefined || payload.exp === null) return false
return payload.exp < Math.floor(Date.now() / 1000)
}

View file

@ -1,11 +0,0 @@
export function shortOrderId(id: string): string {
return id.slice(0, 8)
}
export function formatOrderDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}

View file

@ -1,45 +0,0 @@
import type { Router } from 'vue-router'
const DEFAULT_SCRIPT_URL = 'https://analytics.bilhej.se/script.js'
export type UmamiConfig = {
websiteId: string
scriptUrl: string
}
export function getUmamiConfig(): UmamiConfig | null {
const websiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID?.trim()
if (!websiteId) {
return null
}
const scriptUrl =
import.meta.env.VITE_UMAMI_SCRIPT_URL?.trim() || DEFAULT_SCRIPT_URL
return { websiteId, scriptUrl }
}
export function trackUmamiPageview(url: string): void {
window.umami?.track((props) => ({ ...props, url }))
}
export function initUmamiAnalytics(router: Router): void {
const config = getUmamiConfig()
if (!config) {
return
}
const script = document.createElement('script')
script.defer = true
script.src = config.scriptUrl
script.setAttribute('data-website-id', config.websiteId)
script.setAttribute('data-auto-track', 'false')
script.onload = () => {
trackUmamiPageview(router.currentRoute.value.fullPath)
}
document.head.appendChild(script)
router.afterEach((to) => {
trackUmamiPageview(to.fullPath)
})
}

Some files were not shown because too many files have changed in this diff Show more