Compare commits
18 commits
feature/ad
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a9d2fe688 | |||
|
|
d9aa2d60af | ||
|
|
00d1f48218 | ||
| 56cbc6fedf | |||
| f60af7237e | |||
| 9a63ff69e7 | |||
| d7739bcd58 | |||
|
|
4b35d8ff30 | ||
| c88fa142d3 | |||
| 81e3968e31 | |||
| 5335ba4f12 | |||
|
|
3d2db1471f | ||
|
|
da54a67d9d | ||
| aa2cb7c4a0 | |||
| 737bc3dc64 | |||
| fa7e48fe02 | |||
| c7eeaf6a6b | |||
| 0b2c58fa82 |
67 changed files with 2813 additions and 1036 deletions
|
|
@ -1,6 +1,66 @@
|
||||||
|
# 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
|
||||||
.env
|
|
||||||
.git
|
|
||||||
frontend/node_modules
|
|
||||||
backend/build
|
backend/build
|
||||||
|
frontend/dist
|
||||||
|
frontend/coverage
|
||||||
|
frontend/node_modules
|
||||||
|
backend/.gradle
|
||||||
|
|
||||||
|
# Test outputs
|
||||||
|
**/build/test-results
|
||||||
|
**/build/reports
|
||||||
|
**/coverage
|
||||||
|
**/.pytest_cache
|
||||||
|
frontend/playwright-report
|
||||||
|
frontend/test-results
|
||||||
|
|
||||||
|
# Local config and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/application-local.yml
|
||||||
|
|
||||||
|
# VCS and editor state
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.github
|
||||||
|
.forgejo
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Documentation (not needed at runtime)
|
||||||
|
README.md
|
||||||
|
REQUIREMENTS.md
|
||||||
|
AGENTS.md
|
||||||
|
CODING_GUIDELINES.md
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Ops scripts (not needed at runtime)
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
# Test source dirs that aren't built into runtime artifacts
|
||||||
frontend/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
|
||||||
13
.env.example
13
.env.example
|
|
@ -24,6 +24,12 @@ 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) ----------
|
||||||
|
|
@ -43,3 +49,10 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: 'Git tag to create for this deploy (e.g. v0.1.2) — not the branch/tag above'
|
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)'
|
||||||
required: true
|
required: false
|
||||||
default: 'v0.1.0'
|
default: 'auto'
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -21,12 +21,36 @@ 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 ${{ github.event.inputs.version }} 2>/dev/null || true
|
git tag -d ${{ env.VERSION }} 2>/dev/null || true
|
||||||
git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
|
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true
|
||||||
git tag ${{ github.event.inputs.version }}
|
git tag ${{ env.VERSION }}
|
||||||
git push origin ${{ github.event.inputs.version }}
|
git push origin ${{ env.VERSION }}
|
||||||
|
|
||||||
- name: Write production .env
|
- name: Write production .env
|
||||||
env:
|
env:
|
||||||
|
|
@ -46,6 +70,7 @@ 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 $$.
|
||||||
|
|
@ -67,6 +92,7 @@ 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
|
||||||
|
|
@ -132,7 +158,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
echo ""
|
echo ""
|
||||||
echo "═══════════════════════════════════════════════════"
|
echo "═══════════════════════════════════════════════════"
|
||||||
echo " Deployed ${{ github.event.inputs.version }} to production"
|
echo " Deployed ${{ env.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
|
||||||
|
|
|
||||||
|
|
@ -170,11 +170,16 @@ export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PR
|
||||||
./gradlew check
|
./gradlew check
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs frontend lint, frontend unit tests (242), backend tests (163), coverage
|
This runs frontend lint, frontend unit tests, backend tests, coverage
|
||||||
thresholds, Flyway checks, and **all 90 E2E tests in Docker**. **Do not commit or
|
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`
|
push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
|
||||||
(runs the same `check` on every `git commit`).
|
(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.
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
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;
|
||||||
|
|
@ -10,6 +14,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -17,6 +22,13 @@ 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();
|
||||||
|
|
@ -46,8 +58,21 @@ 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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ 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.RegisterShipmentRequest;
|
||||||
import se.bilhalsning.dto.UpdateAdminNotesRequest;
|
import se.bilhalsning.dto.UpdateAdminNotesRequest;
|
||||||
import se.bilhalsning.dto.UpdateStatusRequest;
|
import se.bilhalsning.dto.UpdateStatusRequest;
|
||||||
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;
|
||||||
|
|
@ -25,11 +27,12 @@ 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(this::toAdminResponse)
|
.map(AdminOrderMapper::toResponse)
|
||||||
.toList();
|
.toList();
|
||||||
return ResponseEntity.ok(orders);
|
return ResponseEntity.ok(orders);
|
||||||
}
|
}
|
||||||
|
|
@ -38,42 +41,26 @@ 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 = orderService.updateOrderStatus(id, request.status());
|
Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status());
|
||||||
return ResponseEntity.ok(toAdminResponse(order));
|
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/orders/{id}/register-shipment")
|
@PatchMapping("/orders/{id}/register-shipment")
|
||||||
public ResponseEntity<AdminOrderResponse> registerShipment(
|
public ResponseEntity<AdminOrderResponse> registerShipment(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@Valid @RequestBody RegisterShipmentRequest request) {
|
@Valid @RequestBody RegisterShipmentRequest request) {
|
||||||
Order order = orderService.registerShipment(
|
Order order = adminOrderWorkflowService.registerShipment(
|
||||||
id,
|
id,
|
||||||
request.trackingInput(),
|
request.trackingInput(),
|
||||||
request.notifyCustomerOrDefault());
|
request.notifyCustomerOrDefault());
|
||||||
return ResponseEntity.ok(toAdminResponse(order));
|
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/orders/{id}/notes")
|
@PatchMapping("/orders/{id}/notes")
|
||||||
public ResponseEntity<AdminOrderResponse> updateNotes(
|
public ResponseEntity<AdminOrderResponse> updateNotes(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@RequestBody UpdateAdminNotesRequest request) {
|
@RequestBody UpdateAdminNotesRequest request) {
|
||||||
Order order = orderService.updateAdminNotes(id, request.adminNotes());
|
Order order = adminOrderWorkflowService.updateAdminNotes(id, request.adminNotes());
|
||||||
return ResponseEntity.ok(toAdminResponse(order));
|
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||||
}
|
|
||||||
|
|
||||||
private AdminOrderResponse toAdminResponse(Order order) {
|
|
||||||
String email = order.getUser() != null ? order.getUser().getEmail() : "";
|
|
||||||
return new AdminOrderResponse(
|
|
||||||
order.getId(),
|
|
||||||
email,
|
|
||||||
order.getPlate(),
|
|
||||||
order.getLetterText(),
|
|
||||||
order.getStatus().getValue(),
|
|
||||||
order.getTrackingId(),
|
|
||||||
order.getAmountPaid(),
|
|
||||||
order.getShippedAt(),
|
|
||||||
order.getAdminNotes(),
|
|
||||||
order.getCreatedAt()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package se.bilhalsning.dto;
|
||||||
|
|
||||||
|
import se.bilhalsning.entity.Order;
|
||||||
|
import se.bilhalsning.service.AdminOrderStatusRules;
|
||||||
|
|
||||||
|
public final class AdminOrderMapper {
|
||||||
|
|
||||||
|
private AdminOrderMapper() {}
|
||||||
|
|
||||||
|
public static AdminOrderResponse toResponse(Order order) {
|
||||||
|
String email = order.getUser() != null ? order.getUser().getEmail() : "";
|
||||||
|
return new AdminOrderResponse(
|
||||||
|
order.getId(),
|
||||||
|
email,
|
||||||
|
order.getPlate(),
|
||||||
|
order.getLetterText(),
|
||||||
|
order.getStatus().getValue(),
|
||||||
|
order.getTrackingId(),
|
||||||
|
order.getAmountPaid(),
|
||||||
|
order.getShippedAt(),
|
||||||
|
order.getAdminNotes(),
|
||||||
|
order.getCreatedAt(),
|
||||||
|
AdminOrderStatusRules.allowedStatusValues(order),
|
||||||
|
AdminOrderStatusRules.canRegisterShipment(order));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ 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(
|
||||||
|
|
@ -14,5 +15,7 @@ public record AdminOrderResponse(
|
||||||
BigDecimal amountPaid,
|
BigDecimal amountPaid,
|
||||||
Instant shippedAt,
|
Instant shippedAt,
|
||||||
String adminNotes,
|
String adminNotes,
|
||||||
Instant createdAt
|
Instant createdAt,
|
||||||
|
List<String> allowedStatuses,
|
||||||
|
boolean canRegisterShipment
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ public class JwtService {
|
||||||
this(secret, DEFAULT_EXPIRATION_MS);
|
this(secret, DEFAULT_EXPIRATION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
JwtService(String secret, long expirationMs) {
|
public 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,8 @@ import se.bilhalsning.entity.OrderStatus;
|
||||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||||
import se.bilhalsning.exception.OrderNotFoundException;
|
import se.bilhalsning.exception.OrderNotFoundException;
|
||||||
import se.bilhalsning.repository.OrderRepository;
|
import se.bilhalsning.repository.OrderRepository;
|
||||||
import se.bilhalsning.util.PostNordTrackingNormalizer;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
|
@ -43,63 +40,6 @@ public class OrderService {
|
||||||
return orderRepository.findAllByOrderByCreatedAtDesc();
|
return orderRepository.findAllByOrderByCreatedAtDesc();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Order updateOrderStatus(UUID orderId, String statusString) {
|
|
||||||
Order order = requireOrder(orderId);
|
|
||||||
OrderStatus newStatus = parseStatus(statusString);
|
|
||||||
OrderStatus previousStatus = order.getStatus();
|
|
||||||
validateAdminStatusTransition(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 (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) {
|
|
||||||
throw new InvalidOrderStateException(
|
|
||||||
"Obetalda misslyckade beställningar kan inte registreras som utskickade");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousStatus != OrderStatus.PROCESSING
|
|
||||||
&& previousStatus != OrderStatus.SENT
|
|
||||||
&& previousStatus != OrderStatus.DELIVERED
|
|
||||||
&& previousStatus != OrderStatus.FAILED) {
|
|
||||||
throw new InvalidOrderStateException(
|
|
||||||
"Beställningen kan inte registreras som utskickad i detta tillstånd");
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -120,11 +60,6 @@ public class OrderService {
|
||||||
return orderRepository.save(order);
|
return orderRepository.save(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Order requireOrder(UUID orderId) {
|
|
||||||
return orderRepository.findWithUserById(orderId)
|
|
||||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
|
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
|
||||||
Order order = orderRepository.findById(orderId)
|
Order order = orderRepository.findById(orderId)
|
||||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||||
|
|
@ -140,53 +75,4 @@ public class OrderService {
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OrderStatus parseStatus(String statusString) {
|
|
||||||
try {
|
|
||||||
return OrderStatus.valueOf(statusString.toUpperCase());
|
|
||||||
} catch (IllegalArgumentException ex) {
|
|
||||||
throw new IllegalArgumentException("Ogiltig status");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void validateAdminStatusTransition(Order order, OrderStatus to) {
|
|
||||||
OrderStatus from = order.getStatus();
|
|
||||||
if (from == to) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<OrderStatus> allowed = switch (from) {
|
|
||||||
case PENDING_PAYMENT -> Set.of(OrderStatus.FAILED);
|
|
||||||
case PROCESSING -> Set.of(OrderStatus.FAILED);
|
|
||||||
case SENT -> Set.of(OrderStatus.DELIVERED, OrderStatus.FAILED);
|
|
||||||
case DELIVERED -> Set.of(OrderStatus.FAILED);
|
|
||||||
case FAILED -> allowedTargetsFromFailed(order);
|
|
||||||
default -> Set.of();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!allowed.contains(to)) {
|
|
||||||
throw new InvalidOrderStateException(
|
|
||||||
"Status kan inte ändras från " + from.getValue() + " till " + to.getValue());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Set<OrderStatus> allowedTargetsFromFailed(Order order) {
|
|
||||||
Set<OrderStatus> allowed = new java.util.HashSet<>();
|
|
||||||
if (hasTrackingId(order)) {
|
|
||||||
allowed.add(OrderStatus.PROCESSING);
|
|
||||||
allowed.add(OrderStatus.SENT);
|
|
||||||
allowed.add(OrderStatus.DELIVERED);
|
|
||||||
} else if (order.getAmountPaid() == null) {
|
|
||||||
allowed.add(OrderStatus.PENDING_PAYMENT);
|
|
||||||
} else {
|
|
||||||
allowed.add(OrderStatus.PROCESSING);
|
|
||||||
allowed.add(OrderStatus.SENT);
|
|
||||||
}
|
|
||||||
return allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean hasTrackingId(Order order) {
|
|
||||||
String trackingId = order.getTrackingId();
|
|
||||||
return trackingId != null && !trackingId.isBlank();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import se.bilhalsning.entity.OrderStatus;
|
||||||
import se.bilhalsning.entity.User;
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
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
|
||||||
|
|
@ -37,17 +38,22 @@ class AdminControllerTest {
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private OrderService orderService;
|
private OrderService orderService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminOrderWorkflowService adminOrderWorkflowService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||||
mockMvc.perform(get("/api/admin/orders"))
|
mockMvc.perform(get("/api/admin/orders"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.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
|
||||||
|
|
@ -62,7 +68,9 @@ 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].status").value("sent"))
|
||||||
|
.andExpect(jsonPath("$[0].allowedStatuses").isArray())
|
||||||
|
.andExpect(jsonPath("$[0].canRegisterShipment").value(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -71,7 +79,8 @@ class AdminControllerTest {
|
||||||
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.FAILED);
|
||||||
|
|
||||||
when(orderService.updateOrderStatus(eq(orderId), eq("failed"))).thenReturn(order);
|
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("failed")))
|
||||||
|
.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)
|
||||||
|
|
@ -84,7 +93,7 @@ class AdminControllerTest {
|
||||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||||
void shouldReturn409WhenStatusTransitionInvalid() throws Exception {
|
void shouldReturn409WhenStatusTransitionInvalid() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
when(orderService.updateOrderStatus(eq(orderId), eq("delivered")))
|
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("delivered")))
|
||||||
.thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
|
.thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
||||||
|
|
@ -101,7 +110,7 @@ class AdminControllerTest {
|
||||||
order.setTrackingId("PN123456789");
|
order.setTrackingId("PN123456789");
|
||||||
order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z"));
|
order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z"));
|
||||||
|
|
||||||
when(orderService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
|
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
|
||||||
.thenReturn(order);
|
.thenReturn(order);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
||||||
|
|
@ -130,7 +139,7 @@ class AdminControllerTest {
|
||||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING);
|
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING);
|
||||||
order.setAdminNotes("Kontaktat TS");
|
order.setAdminNotes("Kontaktat TS");
|
||||||
|
|
||||||
when(orderService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
|
when(adminOrderWorkflowService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
|
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
|
@ -143,7 +152,7 @@ class AdminControllerTest {
|
||||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||||
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception {
|
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
when(orderService.registerShipment(eq(orderId), eq("PN123"), anyBoolean()))
|
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123"), anyBoolean()))
|
||||||
.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}/register-shipment", orderId)
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,8 @@ class AuthControllerTest {
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(
|
.content(
|
||||||
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.message").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -263,6 +264,7 @@ 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().isForbidden());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.message").exists());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,22 @@ 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;
|
||||||
|
|
||||||
|
|
@ -38,9 +44,10 @@ class OrderControllerTest {
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||||
mockMvc.perform(get("/api/orders"))
|
mockMvc.perform(get("/api/orders"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.message").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -100,11 +107,31 @@ class OrderControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn403WhenPostingWithoutAuth() throws Exception {
|
void shouldReturn401WhenPostingWithoutAuth() 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().isForbidden());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.message").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception {
|
||||||
|
JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L);
|
||||||
|
String expiredToken = expiredJwtService.generateToken("test@bilhej.se");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/orders")
|
||||||
|
.header("Authorization", "Bearer " + expiredToken))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.message").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/orders"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.message")
|
||||||
|
.value(org.hamcrest.Matchers.containsString("session")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,11 @@ class PaymentControllerTest {
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
void shouldReturn401WhenNotAuthenticated() 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().isForbidden());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.message").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -253,150 +253,4 @@ class OrderServiceTest {
|
||||||
() -> orderService.confirmPayment(orderId, otherUserId));
|
() -> orderService.confirmPayment(orderId, otherUserId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 = orderService.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,
|
|
||||||
() -> orderService.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,
|
|
||||||
() -> orderService.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 = orderService.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 = orderService.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 java.math.BigDecimal("49.00"));
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.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 java.math.BigDecimal("49.00"));
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.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 java.math.BigDecimal("49.00"));
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.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 = orderService.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));
|
|
||||||
|
|
||||||
orderService.updateOrderStatus(orderId, "failed");
|
|
||||||
|
|
||||||
verify(orderNotificationService).notifyOrderFailed(any(Order.class));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
docker-compose.dev-bindless.yml
Normal file
106
docker-compose.dev-bindless.yml
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# 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:
|
||||||
|
|
@ -49,6 +49,9 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,15 @@
|
||||||
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"]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
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"]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
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/ .
|
||||||
|
|
|
||||||
81
docs/umami-analytics.md
Normal file
81
docs/umami-analytics.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Umami analytics (BilHej app)
|
||||||
|
|
||||||
|
Privacy-friendly page analytics via self-hosted [Umami](https://umami.is/docs) on **`https://analytics.bilhej.se`**. Server install is live on the VPS; this doc is for **BilHej code and deploy config** only.
|
||||||
|
|
||||||
|
## Values (production)
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|--------|
|
||||||
|
| Collector | `https://analytics.bilhej.se` |
|
||||||
|
| Tracker script | `https://analytics.bilhej.se/script.js` |
|
||||||
|
| Dashboard | `https://analytics.bilhej.se` (admin login) |
|
||||||
|
| Website in Umami | Name **BilHej**, domain **`bilhej.se`** |
|
||||||
|
| Website ID | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
|
||||||
|
|
||||||
|
The Website ID is public in the browser (tracking snippet). Set it via **`VITE_UMAMI_WEBSITE_ID`** in production frontend build env — do not hardcode in source.
|
||||||
|
|
||||||
|
**Note:** Umami 3.1 on this server uses the default **`/script.js`** path. `TRACKER_SCRIPT_NAME` / custom `bilhej-stats.js` is not applied in this version.
|
||||||
|
|
||||||
|
### Example snippet (for reference)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://analytics.bilhej.se/script.js"
|
||||||
|
data-website-id="ce59614c-9f2a-4f99-8ba3-c5217f88c3f7"
|
||||||
|
></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend env
|
||||||
|
|
||||||
|
Production builds read this from the **Forgejo Actions secret** `VITE_UMAMI_WEBSITE_ID`. The deploy workflow writes it into `.env` on the server, then `docker compose -f docker-compose.prod.yml build` bakes it into the frontend image.
|
||||||
|
|
||||||
|
**Forgejo → Repository → Settings → Actions → Secrets:**
|
||||||
|
|
||||||
|
| Secret | Value |
|
||||||
|
|--------|--------|
|
||||||
|
| `VITE_UMAMI_WEBSITE_ID` | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
|
||||||
|
|
||||||
|
Not a high-risk secret (the same ID appears in the browser), but keeping it in Forgejo matches other deploy config.
|
||||||
|
|
||||||
|
Manual deploy on the server (without Forgejo) works the same way: put the line in the project `.env` before `docker compose ... up --build`.
|
||||||
|
|
||||||
|
Optional override (default matches production):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `VITE_UMAMI_WEBSITE_ID` unset in local dev unless you intentionally send traffic to production Umami.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
- [x] Load `script.js` with `data-website-id` from `VITE_UMAMI_WEBSITE_ID` (only when set).
|
||||||
|
- [x] Send **SPA pageviews** on Vue Router `afterEach` (`data-auto-track="false"`).
|
||||||
|
- [x] Update **integritetspolicy** — analytics, country-level stats, no IP stored in BilHej DB.
|
||||||
|
- [x] Admin link **Webbstatistik** → Umami dashboard (prod builds only).
|
||||||
|
|
||||||
|
Umami derives **country** from the visitor IP at ingest and does not show IP lists in the UI. BilHej must not store visitor IPs for analytics.
|
||||||
|
|
||||||
|
## Verify after deploy
|
||||||
|
|
||||||
|
1. Browse `https://bilhej.se` (several routes).
|
||||||
|
2. Umami → **BilHej** → **Realtime** / **Countries**.
|
||||||
|
|
||||||
|
## Server layout (reference)
|
||||||
|
|
||||||
|
| Item | Actual on VPS |
|
||||||
|
|------|----------------|
|
||||||
|
| Compose project | `~/umami` (`/home/jocke/umami`) |
|
||||||
|
| Public access | nginx → `http://umami:3000` on Docker network `web` (host port 3000 used by open-webui) |
|
||||||
|
| Database | `umami-db` on internal network `umami-internal` only |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/umami
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f umami
|
||||||
|
docker compose pull && docker compose up -d # updates — read release notes first
|
||||||
|
docker exec umami-db pg_dump -U umami umami > ~/umami-backup-$(date +%F).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Country stats require nginx to pass **`X-Forwarded-For`** (already configured for this vhost).
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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)
|
||||||
|
|
@ -12,11 +13,7 @@ function rowByPlate(page: import('@playwright/test').Page, plate: string) {
|
||||||
|
|
||||||
test.describe('Admin dashboard', () => {
|
test.describe('Admin dashboard', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await loginAsAdmin(page)
|
||||||
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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin order status and shipment flows (serial — mutates seeded orders).
|
* Admin order status and shipment flows (serial — mutates seeded orders).
|
||||||
|
|
@ -10,19 +11,6 @@ const PENDING_ORDER_SHORT_ID = 'c2eebc99'
|
||||||
const PROCESSING_PLATE = 'JKL012'
|
const PROCESSING_PLATE = 'JKL012'
|
||||||
const SENT_ORDER_SHORT_ID = 'c1eebc99'
|
const SENT_ORDER_SHORT_ID = 'c1eebc99'
|
||||||
|
|
||||||
async function loginAsAdmin(page: import('@playwright/test').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('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openAdmin(page: import('@playwright/test').Page) {
|
|
||||||
await page.goto('/admin')
|
|
||||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderRowByPlate(page: import('@playwright/test').Page, plate: string) {
|
function orderRowByPlate(page: import('@playwright/test').Page, plate: string) {
|
||||||
return page.locator('.admin__row').filter({
|
return page.locator('.admin__row').filter({
|
||||||
has: page.locator('.admin__plate', { hasText: plate }),
|
has: page.locator('.admin__plate', { hasText: plate }),
|
||||||
|
|
@ -39,7 +27,7 @@ function orderRowByShortId(
|
||||||
test.describe('Admin fulfillment flows', () => {
|
test.describe('Admin fulfillment flows', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAsAdmin(page)
|
await loginAsAdmin(page)
|
||||||
await openAdmin(page)
|
await openAdminDashboard(page)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('can mark unpaid order as failed', async ({ page }) => {
|
test('can mark unpaid order as failed', async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,13 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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' })
|
||||||
|
|
||||||
|
|
@ -40,8 +41,7 @@ test.describe('Deferred payment and admin lookup', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAdminTodoBoard(page: import('@playwright/test').Page) {
|
async function openAdminTodoBoard(page: import('@playwright/test').Page) {
|
||||||
await page.goto('/admin')
|
await openAdminDashboard(page)
|
||||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
|
||||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||||
await expect(page.locator('.admin__stat--active')).toContainText('Att göra')
|
await expect(page.locator('.admin__stat--active')).toContainText('Att göra')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
frontend/e2e/expired-token.spec.ts
Normal file
55
frontend/e2e/expired-token.spec.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Expired token logout', () => {
|
||||||
|
test('router guard redirects expired token to login and logs out', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const past = Math.floor(Date.now() / 1000) - 3600
|
||||||
|
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past })
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||||
|
|
||||||
|
await page.goto('/orders')
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||||
|
|
||||||
|
const header = page.locator('header')
|
||||||
|
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
header.getByRole('button', { name: 'Logga ut' }),
|
||||||
|
).not.toBeVisible()
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||||
|
expect(stored).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const future = Math.floor(Date.now() / 1000) + 3600
|
||||||
|
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future })
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||||
|
|
||||||
|
await page.goto('/orders')
|
||||||
|
await page.waitForURL(/\/logga-in\?redirect=\/orders/)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||||
|
|
||||||
|
const header = page.locator('header')
|
||||||
|
await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible()
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||||
|
expect(stored).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeJwt(payload: Record<string, unknown>): string {
|
||||||
|
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||||
|
const body = btoa(JSON.stringify(payload))
|
||||||
|
const signature = 'test-sig'
|
||||||
|
return `${header}.${body}.${signature}`
|
||||||
|
}
|
||||||
|
|
@ -100,6 +100,13 @@ 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(
|
||||||
|
|
|
||||||
15
frontend/e2e/helpers/admin.ts
Normal file
15
frontend/e2e/helpers/admin.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
export async function loginAsAdmin(page: Page) {
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
|
||||||
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
|
await page.waitForURL('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openAdminDashboard(page: Page) {
|
||||||
|
await page.goto('/admin')
|
||||||
|
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,31 @@ 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(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Jag har betalat' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows QR code for desktop scanning', async ({ page }) => {
|
||||||
|
await page.goto('/compose?plate=QRA222')
|
||||||
|
await page.getByLabel('Ditt meddelande').fill('Fin bil!')
|
||||||
|
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||||
|
|
||||||
|
await page.waitForURL(/\/betalning\//)
|
||||||
|
await expect(page.getByRole('img', { name: 'Swish QR-kod' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Skanna QR-koden')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows Swish payment link with pre-filled data', async ({ page }) => {
|
||||||
|
await page.goto('/compose?plate=MNO345')
|
||||||
|
await page.getByLabel('Ditt meddelande').fill('Hej där!')
|
||||||
|
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||||
|
|
||||||
|
await page.waitForURL(/\/betalning\//)
|
||||||
|
const swishLink = page.getByRole('link', { name: 'Betala med Swish' })
|
||||||
|
await expect(swishLink).toBeVisible()
|
||||||
|
const href = await swishLink.getAttribute('href')
|
||||||
|
expect(href).toContain('app.swish.nu')
|
||||||
|
expect(href).toContain('amt=49.00')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
356
frontend/package-lock.json
generated
356
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -16,6 +17,7 @@
|
||||||
"@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",
|
||||||
|
|
@ -791,9 +793,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -811,9 +810,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -831,9 +827,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -851,9 +844,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -871,9 +861,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -891,9 +878,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1054,6 +1038,16 @@
|
||||||
"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",
|
||||||
|
|
@ -1962,6 +1956,15 @@
|
||||||
"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",
|
||||||
|
|
@ -1987,11 +1990,91 @@
|
||||||
"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"
|
||||||
|
|
@ -2004,7 +2087,6 @@
|
||||||
"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": {
|
||||||
|
|
@ -2136,6 +2218,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
|
|
@ -2160,6 +2251,12 @@
|
||||||
"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",
|
||||||
|
|
@ -2718,6 +2815,15 @@
|
||||||
"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",
|
||||||
|
|
@ -2863,7 +2969,6 @@
|
||||||
"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"
|
||||||
|
|
@ -3285,9 +3390,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3309,9 +3411,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3333,9 +3432,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3357,9 +3453,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3743,6 +3836,15 @@
|
||||||
"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",
|
||||||
|
|
@ -3787,7 +3889,6 @@
|
||||||
"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"
|
||||||
|
|
@ -3936,6 +4037,15 @@
|
||||||
"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",
|
||||||
|
|
@ -4034,6 +4144,23 @@
|
||||||
"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",
|
||||||
|
|
@ -4084,6 +4211,15 @@
|
||||||
"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",
|
||||||
|
|
@ -4094,6 +4230,12 @@
|
||||||
"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",
|
||||||
|
|
@ -4208,6 +4350,12 @@
|
||||||
"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",
|
||||||
|
|
@ -5094,6 +5242,12 @@
|
||||||
"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",
|
||||||
|
|
@ -5236,6 +5390,12 @@
|
||||||
"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",
|
||||||
|
|
@ -5251,6 +5411,134 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
},
|
},
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
"@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",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ const mockOrders = [
|
||||||
shippedAt: '2026-05-13T12:00:00Z',
|
shippedAt: '2026-05-13T12:00:00Z',
|
||||||
adminNotes: null,
|
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',
|
||||||
|
|
@ -58,6 +60,8 @@ const mockOrders = [
|
||||||
shippedAt: null,
|
shippedAt: null,
|
||||||
adminNotes: 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',
|
||||||
|
|
@ -70,6 +74,8 @@ const mockOrders = [
|
||||||
shippedAt: null,
|
shippedAt: null,
|
||||||
adminNotes: null,
|
adminNotes: null,
|
||||||
createdAt: '2026-05-15T14:00:00Z',
|
createdAt: '2026-05-15T14:00:00Z',
|
||||||
|
allowedStatuses: ['pending_payment', 'failed'],
|
||||||
|
canRegisterShipment: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,26 @@ 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,
|
||||||
|
|
@ -376,3 +396,34 @@ 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' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,26 @@ 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({
|
||||||
|
|
@ -59,6 +71,7 @@ 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 () => {
|
||||||
|
|
@ -81,7 +94,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(
|
||||||
'Ange beställnings-ID ovan som meddelande i Swish-appen',
|
'fylls i automatiskt via QR-kod eller länk',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -93,13 +106,30 @@ 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('.btn--primary').exists()).toBe(true)
|
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.btn--primary').trigger('click')
|
await wrapper.find('.payment__submit').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')
|
||||||
|
|
@ -110,15 +140,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('.btn--primary').exists()).toBe(true)
|
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.btn--primary').trigger('click')
|
await wrapper.find('.payment__submit').trigger('click')
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(wrapper.text()).toContain('Avbryt')
|
expect(wrapper.text()).toContain('Avbryt')
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.btn--ghost').trigger('click')
|
await wrapper.find('.payment__confirm-cancel').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')
|
||||||
|
|
@ -137,16 +167,15 @@ describe('PaymentRedirect', () => {
|
||||||
|
|
||||||
const { wrapper } = await mountPage()
|
const { wrapper } = await mountPage()
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.btn--primary').trigger('click')
|
await wrapper.find('.payment__submit').trigger('click')
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||||
})
|
})
|
||||||
|
|
||||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
|
||||||
|
|
||||||
expect(mockPayOrder).toHaveBeenCalledWith('order-1')
|
expect(mockPayOrder).toHaveBeenCalledWith('order-1')
|
||||||
})
|
})
|
||||||
|
|
@ -156,16 +185,15 @@ describe('PaymentRedirect', () => {
|
||||||
|
|
||||||
const { wrapper } = await mountPage()
|
const { wrapper } = await mountPage()
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.btn--primary').trigger('click')
|
await wrapper.find('.payment__submit').trigger('click')
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||||
})
|
})
|
||||||
|
|
||||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||||
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')
|
||||||
|
|
@ -184,16 +212,15 @@ describe('PaymentRedirect', () => {
|
||||||
|
|
||||||
const { wrapper, router } = await mountPage()
|
const { wrapper, router } = await mountPage()
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('.btn--primary').trigger('click')
|
await wrapper.find('.payment__submit').trigger('click')
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||||
})
|
})
|
||||||
|
|
||||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||||
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')
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,16 @@ 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, {
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,64 @@ 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))
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,52 @@ 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))
|
||||||
|
|
|
||||||
125
frontend/src/__tests__/client.spec.ts
Normal file
125
frontend/src/__tests__/client.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
72
frontend/src/__tests__/umami.spec.ts
Normal file
72
frontend/src/__tests__/umami.spec.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -11,6 +11,8 @@ export interface AdminOrder {
|
||||||
shippedAt: string | null
|
shippedAt: string | null
|
||||||
adminNotes: string | null
|
adminNotes: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
allowedStatuses: string[]
|
||||||
|
canRegisterShipment: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAllOrders(): Promise<AdminOrder[]> {
|
export function fetchAllOrders(): Promise<AdminOrder[]> {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
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 {
|
||||||
|
|
@ -10,10 +13,27 @@ 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 = {},
|
||||||
|
|
@ -34,6 +54,9 @@ 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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,44 @@ 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
|
||||||
|
}
|
||||||
|
|
|
||||||
133
frontend/src/components/admin/AdminOrderDetailPanel.vue
Normal file
133
frontend/src/components/admin/AdminOrderDetailPanel.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AdminOrder } from '@/api/admin'
|
||||||
|
import { postNordTrackingUrl } from '@/constants/orderStatus'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
order: AdminOrder
|
||||||
|
trackingInput: string
|
||||||
|
adminNotes: string
|
||||||
|
notifyCustomer: boolean
|
||||||
|
trackingError: string
|
||||||
|
notesError: string
|
||||||
|
registering: boolean
|
||||||
|
savingNotes: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:trackingInput': [value: string]
|
||||||
|
'update:adminNotes': [value: string]
|
||||||
|
'update:notifyCustomer': [value: boolean]
|
||||||
|
registerShipment: []
|
||||||
|
saveNotes: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin__expanded-inner">
|
||||||
|
<ol v-if="order.status === 'processing'" class="admin__checklist">
|
||||||
|
<li>Hämta ägaradress via Transportstyrelsen</li>
|
||||||
|
<li>Skriv ut brevet och lägg i kuvert</li>
|
||||||
|
<li>Skicka med PostNord och få spårnings-ID</li>
|
||||||
|
<li>Registrera utskick nedan</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div v-if="order.canRegisterShipment" class="admin__section">
|
||||||
|
<div class="admin__section-header">
|
||||||
|
<span class="admin__section-label">Registrera utskick</span>
|
||||||
|
<a
|
||||||
|
v-if="order.trackingId"
|
||||||
|
class="admin__tracking-link"
|
||||||
|
:href="postNordTrackingUrl(order.trackingId)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
Spåra hos PostNord
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="admin__section-hint">
|
||||||
|
Klistra in spårnings-ID eller PostNord-länk. Vid beställningar som
|
||||||
|
hanteras markeras brevet som skickat och kunden kan få e-post.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="trackingError"
|
||||||
|
class="message message--error admin__tracking-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ trackingError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="admin__tracking-row">
|
||||||
|
<label :for="`tracking-${order.id}`" class="visually-hidden"
|
||||||
|
>Spårnings-ID</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="`tracking-${order.id}`"
|
||||||
|
class="admin__tracking-input"
|
||||||
|
type="text"
|
||||||
|
:value="trackingInput"
|
||||||
|
placeholder="PN... eller PostNord-länk"
|
||||||
|
@input="
|
||||||
|
emit(
|
||||||
|
'update:trackingInput',
|
||||||
|
($event.target as HTMLInputElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn--primary btn--sm"
|
||||||
|
:disabled="registering"
|
||||||
|
@click.stop="emit('registerShipment')"
|
||||||
|
>
|
||||||
|
{{ registering ? 'Registrerar...' : 'Registrera utskick' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label v-if="order.status === 'processing'" class="admin__notify">
|
||||||
|
<input
|
||||||
|
:checked="notifyCustomer"
|
||||||
|
type="checkbox"
|
||||||
|
@change="
|
||||||
|
emit(
|
||||||
|
'update:notifyCustomer',
|
||||||
|
($event.target as HTMLInputElement).checked,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
Skicka e-post till kund
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin__section">
|
||||||
|
<span class="admin__section-label">Interna anteckningar</span>
|
||||||
|
<p v-if="notesError" class="message message--error" role="alert">
|
||||||
|
{{ notesError }}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
:id="`notes-${order.id}`"
|
||||||
|
class="admin__notes-input"
|
||||||
|
rows="3"
|
||||||
|
placeholder="T.ex. TS-begäran skickad..."
|
||||||
|
:value="adminNotes"
|
||||||
|
@input="
|
||||||
|
emit(
|
||||||
|
'update:adminNotes',
|
||||||
|
($event.target as HTMLTextAreaElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn--ghost btn--sm admin__notes-save"
|
||||||
|
:disabled="savingNotes"
|
||||||
|
@click.stop="emit('saveNotes')"
|
||||||
|
>
|
||||||
|
{{ savingNotes ? 'Sparar...' : 'Spara anteckningar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
55
frontend/src/components/admin/AdminOrderMessageModal.vue
Normal file
55
frontend/src/components/admin/AdminOrderMessageModal.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<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>
|
||||||
176
frontend/src/components/admin/AdminOrdersTable.vue
Normal file
176
frontend/src/components/admin/AdminOrdersTable.vue
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<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>
|
||||||
71
frontend/src/components/admin/AdminStatsBar.vue
Normal file
71
frontend/src/components/admin/AdminStatsBar.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<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>
|
||||||
159
frontend/src/composables/useAdminOrderActions.ts
Normal file
159
frontend/src/composables/useAdminOrderActions.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
92
frontend/src/composables/useAdminOrders.ts
Normal file
92
frontend/src/composables/useAdminOrders.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/src/constants/orderStatus.ts
Normal file
30
frontend/src/constants/orderStatus.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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
Normal file
22
frontend/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string
|
||||||
|
readonly VITE_UMAMI_WEBSITE_ID?: string
|
||||||
|
readonly VITE_UMAMI_SCRIPT_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
umami?: {
|
||||||
|
track: (
|
||||||
|
input?:
|
||||||
|
| string
|
||||||
|
| Record<string, unknown>
|
||||||
|
| ((props: Record<string, unknown>) => Record<string, unknown>),
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,13 @@ 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')
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,45 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, reactive, computed } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { ApiError } from '@/api/client'
|
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'
|
||||||
registerShipment,
|
import AdminOrderMessageModal from '@/components/admin/AdminOrderMessageModal.vue'
|
||||||
updateAdminNotes,
|
|
||||||
type AdminOrder,
|
|
||||||
} from '@/api/admin'
|
|
||||||
|
|
||||||
const orders = ref<AdminOrder[]>([])
|
const {
|
||||||
const expandedOrderId = ref<string | null>(null)
|
orders,
|
||||||
const loading = ref(true)
|
loading,
|
||||||
const error = ref('')
|
error,
|
||||||
const statusError = ref('')
|
activeFilter,
|
||||||
const trackingError = ref('')
|
searchQuery,
|
||||||
const activeFilter = ref<
|
stats,
|
||||||
'all' | 'processing' | 'paid_group' | 'pending_payment'
|
filteredOrders,
|
||||||
>('all')
|
loadOrders,
|
||||||
const searchQuery = ref('')
|
replaceOrder,
|
||||||
const trackingInputValues = reactive<Record<string, string>>({})
|
} = useAdminOrders()
|
||||||
const adminNotesValues = reactive<Record<string, string>>({})
|
|
||||||
const notifyCustomerValues = reactive<Record<string, boolean>>({})
|
|
||||||
const messageModalOrder = ref<AdminOrder | null>(null)
|
|
||||||
const notesError = ref('')
|
|
||||||
const savingNotesId = ref<string | null>(null)
|
|
||||||
const registeringId = ref<string | null>(null)
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const {
|
||||||
pending_payment: 'Väntar på betalning',
|
expandedOrderId,
|
||||||
paid: 'Betalad',
|
statusError,
|
||||||
processing: 'Hanteras',
|
trackingError,
|
||||||
sent: 'Skickat',
|
notesError,
|
||||||
delivered: 'Levererat',
|
savingNotesId,
|
||||||
failed: 'Misslyckad',
|
registeringId,
|
||||||
cancelled: 'Avbruten',
|
messageModalOrder,
|
||||||
}
|
trackingInputValues,
|
||||||
|
adminNotesValues,
|
||||||
|
notifyCustomerValues,
|
||||||
|
openMessageModal,
|
||||||
|
closeMessageModal,
|
||||||
|
toggleExpand,
|
||||||
|
handleStatusChange,
|
||||||
|
handleRegisterShipment,
|
||||||
|
handleNotesSave,
|
||||||
|
} = useAdminOrderActions(orders, replaceOrder)
|
||||||
|
|
||||||
const statusBadge: Record<string, string> = {
|
const umamiDashboardUrl = import.meta.env.VITE_UMAMI_WEBSITE_ID
|
||||||
pending_payment: 'badge--muted',
|
? 'https://analytics.bilhej.se'
|
||||||
paid: 'badge--success',
|
: null
|
||||||
processing: 'badge--primary',
|
|
||||||
sent: 'badge--success',
|
|
||||||
delivered: 'badge--success',
|
|
||||||
failed: 'badge--danger',
|
|
||||||
cancelled: 'badge--muted',
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = computed(() => {
|
|
||||||
const total = orders.value.length
|
|
||||||
const todo = orders.value.filter((o) => o.status === 'processing').length
|
|
||||||
const paid = orders.value.filter((o) =>
|
|
||||||
['processing', '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) =>
|
|
||||||
['processing', '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) {
|
||||||
|
|
@ -110,149 +47,9 @@ function handleModalKeydown(event: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function allowedStatuses(order: AdminOrder): string[] {
|
onMounted(() => {
|
||||||
switch (order.status) {
|
|
||||||
case 'pending_payment':
|
|
||||||
return ['pending_payment', 'failed']
|
|
||||||
case 'processing':
|
|
||||||
return ['processing', 'failed']
|
|
||||||
case 'sent':
|
|
||||||
return ['sent', 'delivered', 'failed']
|
|
||||||
case 'delivered':
|
|
||||||
return ['delivered', 'failed']
|
|
||||||
case 'failed': {
|
|
||||||
const options = ['failed']
|
|
||||||
if (order.trackingId) {
|
|
||||||
options.push('processing', 'sent', 'delivered')
|
|
||||||
} else if (order.amountPaid == null) {
|
|
||||||
options.push('pending_payment')
|
|
||||||
} else {
|
|
||||||
options.push('processing', 'sent')
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return [order.status]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStatusDropdownDisabled(order: AdminOrder): boolean {
|
|
||||||
return allowedStatuses(order).length <= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function canRegisterShipment(order: AdminOrder): boolean {
|
|
||||||
if (['processing', 'sent', 'delivered'].includes(order.status)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return order.status === 'failed' && order.amountPaid != null
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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 = 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 (err) {
|
|
||||||
order.status = previousStatus
|
|
||||||
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 = orders.value.find((o) => o.id === 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,
|
|
||||||
)
|
|
||||||
order.status = updated.status
|
|
||||||
order.trackingId = updated.trackingId
|
|
||||||
order.shippedAt = updated.shippedAt
|
|
||||||
trackingInputValues[orderId] = updated.trackingId ?? trackingInput
|
|
||||||
} catch {
|
|
||||||
order.status = previousStatus
|
|
||||||
order.trackingId = previousTrackingId
|
|
||||||
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 = orders.value.find((o) => o.id === 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)
|
|
||||||
order.adminNotes = updated.adminNotes
|
|
||||||
adminNotesValues[orderId] = updated.adminNotes ?? ''
|
|
||||||
} catch {
|
|
||||||
order.adminNotes = previousNotes
|
|
||||||
adminNotesValues[orderId] = previousNotes ?? ''
|
|
||||||
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
|
|
||||||
} finally {
|
|
||||||
savingNotesId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
window.addEventListener('keydown', handleModalKeydown)
|
window.addEventListener('keydown', handleModalKeydown)
|
||||||
try {
|
void loadOrders()
|
||||||
orders.value = await fetchAllOrders()
|
|
||||||
} catch {
|
|
||||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
@ -262,7 +59,18 @@ 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"
|
||||||
|
|
@ -279,57 +87,14 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="admin__stats">
|
<AdminStatsBar
|
||||||
<button
|
v-model:active-filter="activeFilter"
|
||||||
type="button"
|
v-model:search-query="searchQuery"
|
||||||
class="admin__stat"
|
:total="stats.total"
|
||||||
:class="{ 'admin__stat--active': activeFilter === 'all' }"
|
:todo="stats.todo"
|
||||||
@click="activeFilter = 'all'"
|
:paid="stats.paid"
|
||||||
>
|
: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"
|
||||||
|
|
@ -338,321 +103,82 @@ onUnmounted(() => {
|
||||||
Inga beställningar matchar filtret.
|
Inga beställningar matchar filtret.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p
|
<AdminOrdersTable
|
||||||
v-if="statusError"
|
v-if="filteredOrders.length > 0"
|
||||||
class="message message--error admin__status-error"
|
:orders="filteredOrders"
|
||||||
role="alert"
|
:expanded-order-id="expandedOrderId"
|
||||||
>
|
:status-error="statusError"
|
||||||
{{ statusError }}
|
:tracking-error="trackingError"
|
||||||
</p>
|
:notes-error="notesError"
|
||||||
|
:tracking-input-values="trackingInputValues"
|
||||||
<div v-if="filteredOrders.length > 0" class="admin__table-wrap">
|
:admin-notes-values="adminNotesValues"
|
||||||
<table class="admin__table">
|
:notify-customer-values="notifyCustomerValues"
|
||||||
<thead>
|
:saving-notes-id="savingNotesId"
|
||||||
<tr>
|
:registering-id="registeringId"
|
||||||
<th class="admin__th-expand" scope="col">
|
@toggle-expand="toggleExpand"
|
||||||
<span class="visually-hidden">Visa detaljer</span>
|
@open-message="openMessageModal"
|
||||||
</th>
|
@status-change="handleStatusChange"
|
||||||
<th class="admin__th-date">Datum</th>
|
@register-shipment="handleRegisterShipment"
|
||||||
<th class="admin__th-id" title="Beställnings-ID">ID</th>
|
@save-notes="handleNotesSave"
|
||||||
<th class="admin__th-email">E-post</th>
|
@update:tracking-input="
|
||||||
<th class="admin__th-plate">Regnr</th>
|
(id, value) => {
|
||||||
<th class="admin__th-message" title="Meddelande">Brev</th>
|
trackingInputValues[id] = value
|
||||||
<th class="admin__th-status">Status</th>
|
}
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template v-for="order in filteredOrders" :key="order.id">
|
|
||||||
<tr
|
|
||||||
class="admin__row"
|
|
||||||
:class="{
|
|
||||||
'admin__row--expanded': expandedOrderId === order.id,
|
|
||||||
'admin__row--todo': order.status === 'processing',
|
|
||||||
}"
|
|
||||||
: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="toggleExpand(order.id)"
|
@update:admin-notes="
|
||||||
>
|
(id, value) => {
|
||||||
<td class="admin__expand-cell">
|
adminNotesValues[id] = value
|
||||||
<span
|
}
|
||||||
class="admin__expand-icon"
|
"
|
||||||
:class="{
|
@update:notify-customer="
|
||||||
'admin__expand-icon--open': expandedOrderId === order.id,
|
(id, value) => {
|
||||||
}"
|
notifyCustomerValues[id] = value
|
||||||
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">
|
|
||||||
{{ formatDate(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="openMessageModal(order)"
|
|
||||||
>
|
|
||||||
Visa meddelande
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td class="admin__status-cell">
|
|
||||||
<select
|
|
||||||
class="admin__status-select"
|
|
||||||
:class="statusBadge[order.status] || 'badge--muted'"
|
|
||||||
:value="order.status"
|
|
||||||
:disabled="isStatusDropdownDisabled(order)"
|
|
||||||
@change="
|
|
||||||
handleStatusChange(
|
|
||||||
order.id,
|
|
||||||
($event.target as HTMLSelectElement).value,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="s in allowedStatuses(order)"
|
|
||||||
:key="s"
|
|
||||||
:value="s"
|
|
||||||
>
|
|
||||||
{{ statusLabels[s] }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-if="expandedOrderId === order.id"
|
|
||||||
class="admin__expanded-row"
|
|
||||||
>
|
|
||||||
<td :colspan="7">
|
|
||||||
<div class="admin__expanded-inner">
|
|
||||||
<ol
|
|
||||||
v-if="order.status === 'processing'"
|
|
||||||
class="admin__checklist"
|
|
||||||
>
|
|
||||||
<li>Hämta ägaradress via Transportstyrelsen</li>
|
|
||||||
<li>Skriv ut brevet och lägg i kuvert</li>
|
|
||||||
<li>Skicka med PostNord och få spårnings-ID</li>
|
|
||||||
<li>Registrera utskick nedan</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="canRegisterShipment(order)"
|
|
||||||
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="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
Spåra hos PostNord
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="admin__section-hint">
|
|
||||||
Klistra in spårnings-ID eller PostNord-länk. Vid
|
|
||||||
beställningar som hanteras markeras brevet som skickat
|
|
||||||
och kunden kan få e-post.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="trackingError"
|
|
||||||
class="message message--error admin__tracking-error"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{{ trackingError }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="admin__tracking-row">
|
|
||||||
<label
|
|
||||||
:for="`tracking-${order.id}`"
|
|
||||||
class="visually-hidden"
|
|
||||||
>Spårnings-ID</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:id="`tracking-${order.id}`"
|
|
||||||
class="admin__tracking-input"
|
|
||||||
type="text"
|
|
||||||
:value="
|
|
||||||
trackingInputValues[order.id] ??
|
|
||||||
order.trackingId ??
|
|
||||||
''
|
|
||||||
"
|
|
||||||
placeholder="PN... eller PostNord-länk"
|
|
||||||
@input="
|
|
||||||
trackingInputValues[order.id] = (
|
|
||||||
$event.target as HTMLInputElement
|
|
||||||
).value
|
|
||||||
"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="btn btn--primary btn--sm"
|
|
||||||
:disabled="registeringId === order.id"
|
|
||||||
@click.stop="handleRegisterShipment(order.id)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
registeringId === order.id
|
|
||||||
? 'Registrerar...'
|
|
||||||
: 'Registrera utskick'
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label
|
|
||||||
v-if="order.status === 'processing'"
|
|
||||||
class="admin__notify"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="notifyCustomerValues[order.id]"
|
|
||||||
type="checkbox"
|
|
||||||
@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="adminNotesValues[order.id] ?? ''"
|
|
||||||
@input="
|
|
||||||
adminNotesValues[order.id] = (
|
|
||||||
$event.target as HTMLTextAreaElement
|
|
||||||
).value
|
|
||||||
"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="btn btn--ghost btn--sm admin__notes-save"
|
|
||||||
:disabled="savingNotesId === order.id"
|
|
||||||
@click.stop="handleNotesSave(order.id)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
savingNotesId === order.id
|
|
||||||
? 'Sparar...'
|
|
||||||
: 'Spara anteckningar'
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<AdminOrderMessageModal
|
||||||
v-if="messageModalOrder"
|
:order="messageModalOrder"
|
||||||
class="admin-modal-overlay"
|
@close="closeMessageModal"
|
||||||
@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 scoped>
|
<style>
|
||||||
.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 0 var(--space-xl) 0;
|
margin: 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);
|
||||||
|
|
@ -945,13 +471,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
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'
|
||||||
|
|
@ -41,8 +42,10 @@ async function handleSubmit() {
|
||||||
params: { orderId: order.id },
|
params: { orderId: order.id },
|
||||||
query: { plate: plate.value },
|
query: { plate: plate.value },
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
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'
|
||||||
|
|
||||||
|
|
@ -44,8 +45,10 @@ async function loadOrder() {
|
||||||
if (fetched.status === 'pending_payment') {
|
if (fetched.status === 'pending_payment') {
|
||||||
letterText.value = fetched.letterText
|
letterText.value = fetched.letterText
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
@ -64,8 +67,10 @@ async function handleSubmit() {
|
||||||
params: { orderId: order.value.id },
|
params: { orderId: order.value.id },
|
||||||
query: { plate: order.value.plate },
|
query: { plate: order.value.plate },
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -42,26 +47,6 @@ 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',
|
||||||
|
|
@ -81,8 +66,10 @@ async function loadOrders() {
|
||||||
])
|
])
|
||||||
orders.value = fetchedOrders
|
orders.value = fetchedOrders
|
||||||
orderAmount.value = swishInfo.amount
|
orderAmount.value = swishInfo.amount
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
@ -103,8 +90,11 @@ 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 {
|
} catch (err) {
|
||||||
actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
|
if (!isSessionExpired(err)) {
|
||||||
|
actionError.value =
|
||||||
|
'Kunde inte avbryta beställningen. Försök igen senare.'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
cancellingId.value = null
|
cancellingId.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +156,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">
|
||||||
{{ statusLabels[order.status] }}
|
{{ ORDER_STATUS_LABELS[order.status] }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -262,9 +252,9 @@ onMounted(loadOrders)
|
||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
class="badge"
|
class="badge"
|
||||||
:class="statusBadge[order.status] || 'badge--muted'"
|
:class="ORDER_STATUS_BADGE[order.status] || 'badge--muted'"
|
||||||
>
|
>
|
||||||
{{ statusLabels[order.status] || order.status }}
|
{{ ORDER_STATUS_LABELS[order.status] || order.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { payOrder, fetchSwishInfo } from '@/api/payment'
|
import QRCode from 'qrcode'
|
||||||
|
import { payOrder, fetchSwishInfo, buildSwishPaymentUrl } from '@/api/payment'
|
||||||
|
import { isSessionExpired } from '@/api/client'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -12,12 +14,27 @@ 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.'
|
||||||
}
|
}
|
||||||
|
|
@ -38,8 +55,10 @@ async function confirmPayment() {
|
||||||
try {
|
try {
|
||||||
await payOrder(orderId)
|
await payOrder(orderId)
|
||||||
await router.push({ name: 'orders' })
|
await router.push({ name: 'orders' })
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
@ -75,21 +94,37 @@ 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">
|
||||||
Ange beställnings-ID ovan som meddelande i Swish-appen.
|
Belopp och beställnings-ID fylls i automatiskt via QR-kod eller
|
||||||
|
länk.
|
||||||
</p>
|
</p>
|
||||||
<p class="payment__swish-instruction">
|
<p class="payment__swish-instruction">
|
||||||
Tryck sedan på knappen nedan för att bekräfta.
|
Betala manuellt om du inte har Swish-appen tillgänglig.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button class="btn btn--ghost payment__submit" @click="startPayment">
|
||||||
class="btn btn--primary btn--lg payment__submit"
|
|
||||||
@click="startPayment"
|
|
||||||
>
|
|
||||||
Jag har betalat
|
Jag har betalat
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -198,6 +233,31 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,15 @@ 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?',
|
||||||
|
|
|
||||||
|
|
@ -148,9 +148,11 @@ 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 && auth.isAuthenticated) return { name: 'home' }
|
if (to.meta.guestOnly && authenticated) return { name: 'home' }
|
||||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
if (to.meta.requiresAuth && !authenticated) {
|
||||||
|
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' }
|
||||||
|
|
|
||||||
|
|
@ -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 } from '@/utils/jwt'
|
import { parseJwtPayload, isTokenExpired as isJwtExpired } 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,6 +21,10 @@ 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)
|
||||||
|
|
@ -69,6 +73,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
email,
|
email,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isTokenExpired,
|
||||||
registerUser,
|
registerUser,
|
||||||
loginUser,
|
loginUser,
|
||||||
changeUserEmail,
|
changeUserEmail,
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,10 @@ 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)
|
||||||
|
}
|
||||||
|
|
|
||||||
11
frontend/src/utils/orderDisplay.ts
Normal file
11
frontend/src/utils/orderDisplay.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
45
frontend/src/utils/umami.ts
Normal file
45
frontend/src/utils/umami.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue