Compare commits

..

No commits in common. "master" and "refactor/admin-fulfillment" have entirely different histories.

76 changed files with 131 additions and 4271 deletions

View file

@ -1,66 +1,6 @@
# Exclude everything that isn't strictly needed to build or run the dev images.
# The dev Dockerfiles COPY source subpaths (frontend/, backend/, gradlew,
# settings.gradle, etc.), so without this the image would bloat with docs,
# scripts, git history, etc.
# Build artifacts and caches (mounted as named volumes at runtime)
.gradle .gradle
backend/build
frontend/dist
frontend/coverage
frontend/node_modules
backend/.gradle
# Test outputs
**/build/test-results
**/build/reports
**/coverage
**/.pytest_cache
frontend/playwright-report
frontend/test-results
# Local config and secrets
.env .env
.env.*
!.env.example
**/application-local.yml
# VCS and editor state
.git .git
.gitignore frontend/node_modules
.gitattributes backend/build
.github
.forgejo
.idea
.vscode
*.iml
.DS_Store
# Documentation (not needed at runtime)
README.md
REQUIREMENTS.md
AGENTS.md
CODING_GUIDELINES.md
docs/
# Ops scripts (not needed at runtime)
scripts/
# Test source dirs that aren't built into runtime artifacts
frontend/src/__tests__ frontend/src/__tests__
backend/src/test
# Docker metadata — Dockerfiles, .dockerignore, and compose files are not
# needed inside the running image. Keep docker/*.conf and docker/entrypoint.sh
# because frontend.prod.Dockerfile copies them into the production nginx image.
docker/*.Dockerfile
Dockerfile*
.dockerignore
docker-compose*.yml
# Misc
*.log
logs/
tmp/
*.bak
*.tmp

View file

@ -24,12 +24,6 @@ STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_... STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ---------- # ---------- Swish (Phase 0) ----------
# The Swish number customers pay to. Two formats accepted:
# - Swedish phone number: 0701234567 (normalised to 46… for the payment URL)
# - Swish Business number: 1234567890 (starts with 123, used as-is)
# A Swish Business number (123…) is recommended — get one from your bank
# via a "Swish Företag" agreement. No Swish Commerce API certificate needed;
# the frontend generates a pre-filled QR code + payment link automatically.
SWISH_NUMBER=0701234567 SWISH_NUMBER=0701234567
# ---------- App URL (password reset links in email) ---------- # ---------- App URL (password reset links in email) ----------
@ -49,10 +43,3 @@ APP_PUBLIC_BASE_URL=http://localhost:3000
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead. # Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
ADMIN_EMAIL=admin@bilhej.se ADMIN_EMAIL=admin@bilhej.se
ADMIN_PASSWORD=change_me_to_a_strong_password ADMIN_PASSWORD=change_me_to_a_strong_password
# ---------- Umami analytics (production frontend build only) ----------
# Baked into the frontend image at build time. Leave unset for local dev / docker compose up.
# Website ID from https://analytics.bilhej.se → Settings → Websites → BilHej
# See docs/umami-analytics.md
# VITE_UMAMI_WEBSITE_ID=
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js

View file

@ -4,9 +4,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)' description: 'Git tag to create for this deploy (e.g. v0.1.2) — not the branch/tag above'
required: false required: true
default: 'auto' default: 'v0.1.0'
type: string type: string
jobs: jobs:
@ -21,36 +21,12 @@ jobs:
git fetch --depth 1 origin ${GITHUB_SHA} git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD git checkout FETCH_HEAD
- name: Resolve version
run: |
INPUT_VERSION="${{ github.event.inputs.version }}"
if [ -z "$INPUT_VERSION" ] || [ "$INPUT_VERSION" = "auto" ]; then
git fetch --tags origin
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then LATEST="v0.0.0"; fi
BASE="${LATEST#v}"
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3)
PATCH=$(( ${PATCH:-0} + 1 ))
VERSION="v${MAJOR:-0}.${MINOR:-0}.${PATCH}"
echo "Latest tag: $LATEST → auto-bumped to $VERSION"
else
VERSION="$INPUT_VERSION"
echo "Using manual version: $VERSION"
fi
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: resolved version '$VERSION' is not valid semver (expected vX.Y.Z)"
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Tag version - name: Tag version
run: | run: |
git tag -d ${{ env.VERSION }} 2>/dev/null || true git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
git tag ${{ env.VERSION }} git tag ${{ github.event.inputs.version }}
git push origin ${{ env.VERSION }} git push origin ${{ github.event.inputs.version }}
- name: Write production .env - name: Write production .env
env: env:
@ -70,7 +46,6 @@ jobs:
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM }} MAIL_FROM: ${{ secrets.MAIL_FROM }}
VITE_UMAMI_WEBSITE_ID: ${{ secrets.VITE_UMAMI_WEBSITE_ID }}
run: | run: |
# Docker Compose treats $ as variable interpolation in .env files. # Docker Compose treats $ as variable interpolation in .env files.
# Escape literal dollar signs (e.g. in passwords) as $$. # Escape literal dollar signs (e.g. in passwords) as $$.
@ -92,7 +67,6 @@ jobs:
printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")" printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")"
printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")" printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")"
printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")" printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")"
printf 'VITE_UMAMI_WEBSITE_ID=%s\n' "$(escape "$VITE_UMAMI_WEBSITE_ID")"
} > .env } > .env
- name: Build and start production stack - name: Build and start production stack
@ -158,7 +132,7 @@ jobs:
run: | run: |
echo "" echo ""
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo " Deployed ${{ env.VERSION }} to production" echo " Deployed ${{ github.event.inputs.version }} to production"
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo "" echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps docker compose -p bilhej-prod -f docker-compose.prod.yml ps

View file

@ -170,16 +170,11 @@ 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, backend tests, coverage This runs frontend lint, frontend unit tests (242), backend tests (163), coverage
thresholds, Flyway checks, and **all E2E tests in Docker**. **Do not commit or thresholds, Flyway checks, and **all 90 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.

View file

@ -342,7 +342,6 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
| `MAIL_USERNAME` | `resend` (literal string) | | `MAIL_USERNAME` | `resend` (literal string) |
| `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) | | `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) |
| `MAIL_FROM` | `noreply@bilhej.se` (must be on verified domain) | | `MAIL_FROM` | `noreply@bilhej.se` (must be on verified domain) |
| `VITE_UMAMI_WEBSITE_ID` | Umami website UUID for `bilhej.se` (see `docs/umami-analytics.md`) |
Passwords may contain `$` — the deploy workflow escapes these for Docker Compose. Passwords may contain `$` — the deploy workflow escapes these for Docker Compose.
Production does **not** seed `test@bilhej.se` or demo orders. On first start, the Production does **not** seed `test@bilhej.se` or demo orders. On first start, the

View file

@ -1,12 +1,8 @@
package se.bilhalsning.config; package se.bilhalsning.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@ -14,7 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import se.bilhalsning.dto.ErrorResponse;
import se.bilhalsning.security.JwtAuthenticationFilter; import se.bilhalsning.security.JwtAuthenticationFilter;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
@ -22,13 +17,6 @@ import se.bilhalsning.security.JwtService;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
static final String UNAUTHENTICATED_MESSAGE =
"Din session har löpt ut eller är ogiltig. Logga in igen.";
static final String FORBIDDEN_MESSAGE =
"Du har inte behörighet att utföra denna åtgärd.";
private final ObjectMapper objectMapper = new ObjectMapper();
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@ -55,25 +43,11 @@ public class SecurityConfig {
.permitAll() .permitAll()
.requestMatchers("/api/webhooks/**").permitAll() .requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/payment/swish-info").permitAll() .requestMatchers("/api/payment/swish-info").permitAll()
.requestMatchers("/api/guest-orders/**").permitAll()
.requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) .anyRequest().authenticated())
.exceptionHandling(eh -> eh
.authenticationEntryPoint((request, response, ex) ->
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
.accessDeniedHandler((request, response, ex) ->
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
private void writeError(HttpServletResponse response, HttpStatus status, String message)
throws java.io.IOException {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message)));
}
} }

View file

@ -1,75 +0,0 @@
package se.bilhalsning.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.CreateGuestOrderRequest;
import se.bilhalsning.dto.GuestOrderResponse;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.OrderService;
import java.util.UUID;
/**
* Public (no-JWT) endpoints for placing and paying for orders without an
* account — guest checkout.
*
* Auth: white-listed in {@code SecurityConfig} so no JWT filter runs on
* these paths. The guest token (UUID v4, generated at order create time
* in {@link Order#onCreate()}) is the only credential the client holds
* and must pass as a path variable. Token brute-force resistance: 122
* bits of entropy.
*
* Routes parallel {@link OrderController} deliberately so the JWT path
* stays clean and unmodified.
*/
@RestController
@RequestMapping("/api/guest-orders")
@RequiredArgsConstructor
public class GuestOrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<GuestOrderResponse> create(
@Valid @RequestBody CreateGuestOrderRequest request) {
Order order = orderService.createGuestOrder(
request.plate(),
request.letterText(),
request.email()
);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
}
@GetMapping("/{token}")
public ResponseEntity<GuestOrderResponse> get(@PathVariable UUID token) {
Order order = orderService.getOrderByGuestToken(token);
return ResponseEntity.ok(toResponse(order));
}
@PostMapping("/{token}/pay")
public ResponseEntity<GuestOrderResponse> pay(@PathVariable UUID token) {
Order order = orderService.confirmGuestPayment(token);
return ResponseEntity.ok(toResponse(order));
}
private GuestOrderResponse toResponse(Order order) {
return new GuestOrderResponse(
order.getId(),
order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt(),
order.getGuestToken()
);
}
}

View file

@ -1,27 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* Create an order without an account (guest checkout).
*
* The {@code plate} validation mirrors {@link CreateOrderRequest} so the
* guest path accepts the same Swedish 3-letter + 3-char plate format.
*/
public record CreateGuestOrderRequest(
@NotBlank(message = "Registreringsnummer krävs")
@Pattern(regexp = "^[A-Za-z]{3}\\d{2}[A-Za-z0-9]$", message = "Ogiltigt registreringsnummer")
String plate,
@NotBlank(message = "Brevtext krävs")
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
String letterText,
@NotBlank(message = "E-post krävs")
@Email(message = "Ogiltig e-postadress")
@Size(max = 255, message = "E-postadressen är för lång")
String email
) {}

View file

@ -1,24 +0,0 @@
package se.bilhalsning.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
/**
* Response shape for guest-order endpoints.
*
* Identical to {@link OrderResponse} plus {@link #guestToken}, which the
* client needs to (a) build the payment page URL and (b) look up the
* order again without an account. Token is returned only on create and
* by-token lookup — it is never re-exposed by order ID alone.
*/
public record GuestOrderResponse(
UUID id,
String plate,
String letterText,
String status,
String trackingId,
BigDecimal amountPaid,
Instant createdAt,
UUID guestToken
) {}

View file

@ -21,12 +21,7 @@ public class Order {
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id; private UUID id;
/** @Column(name = "user_id", nullable = false, columnDefinition = "uuid")
* Null for guest orders (no registered user). FK to {@code users(id)}
* is still in place — NULL is FK-legal. Either {@code userId} or
* {@code guestToken} is set; never both, never neither.
*/
@Column(name = "user_id", columnDefinition = "uuid")
private UUID userId; private UUID userId;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@ -60,31 +55,11 @@ public class Order {
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private Instant updatedAt; private Instant updatedAt;
/**
* Guest contact email. Stored at order create time so a magic link
* (carrying {@code guestToken}) can be emailed to the customer later.
*/
@Column(name = "guest_email", length = 255)
private String guestEmail;
/**
* Opaque UUID v4 token. The only credential a guest holds. Used as
* the path variable on all {@code /api/guest-orders/{token}/...}
* endpoints. Generated in {@link #onCreate()} for guest orders only.
*/
@Column(name = "guest_token", columnDefinition = "uuid")
private UUID guestToken;
@PrePersist @PrePersist
void onCreate() { void onCreate() {
if (this.id == null) { if (this.id == null) {
this.id = UUID.randomUUID(); this.id = UUID.randomUUID();
} }
// Guest orders (no userId) get a token for unauthenticated lookup.
// User-owned orders never get one — they go through JWT + userId.
if (this.guestToken == null && this.userId == null) {
this.guestToken = UUID.randomUUID();
}
Instant now = Instant.now(); Instant now = Instant.now();
if (this.createdAt == null) { if (this.createdAt == null) {
this.createdAt = now; this.createdAt = now;
@ -184,20 +159,4 @@ public class Order {
public Instant getUpdatedAt() { public Instant getUpdatedAt() {
return updatedAt; return updatedAt;
} }
public String getGuestEmail() {
return guestEmail;
}
public void setGuestEmail(String guestEmail) {
this.guestEmail = guestEmail;
}
public UUID getGuestToken() {
return guestToken;
}
public void setGuestToken(UUID guestToken) {
this.guestToken = guestToken;
}
} }

View file

@ -21,8 +21,4 @@ public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = {"user"}) @EntityGraph(attributePaths = {"user"})
Optional<Order> findWithUserById(UUID id); Optional<Order> findWithUserById(UUID id);
// Guest checkout — looks up by opaque token, no JWT. Partial-unique
// index on (guest_token) WHERE NOT NULL enforces uniqueness on writes.
Optional<Order> findByGuestToken(UUID guestToken);
} }

View file

@ -18,7 +18,7 @@ public class JwtService {
this(secret, DEFAULT_EXPIRATION_MS); this(secret, DEFAULT_EXPIRATION_MS);
} }
public JwtService(String secret, long expirationMs) { JwtService(String secret, long expirationMs) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
this.expirationMs = expirationMs; this.expirationMs = expirationMs;
} }

View file

@ -27,56 +27,6 @@ public class OrderService {
return orderRepository.save(order); return orderRepository.save(order);
} }
/**
* Guest checkout — creates an order with no registered user. The
* {@code guestToken} is generated in {@link Order#onCreate()} so the
* caller does not need to handle token creation logic. Returned
* order's {@link Order#getGuestToken()} is the only credential the
* client receives.
*/
public Order createGuestOrder(String plate, String letterText, String email) {
Order order = new Order();
// userId stays null — guest order. guestToken auto-generated in PrePersist.
order.setGuestEmail(email == null ? null : email.trim().toLowerCase());
order.setPlate(plate.toUpperCase().trim());
order.setLetterText(letterText);
order.setStatus(OrderStatus.PENDING_PAYMENT);
return orderRepository.save(order);
}
/**
* Guest-path order lookup by opaque token. Never exposes user-owned
* orders via this path: if a guest token resolves to an order with a
* non-null {@code userId} (corrupted data, manual SQL insert, etc.),
* treat as not-found rather than leak the order.
*/
public Order getOrderByGuestToken(UUID guestToken) {
Order order = orderRepository.findByGuestToken(guestToken)
.orElseThrow(() -> new OrderNotFoundException(guestToken));
if (order.getUserId() != null) {
// Token points at a user-owned order — refuse to serve it.
throw new OrderNotFoundException(guestToken);
}
return order;
}
/**
* Honor-system payment confirmation for guest orders. Mirrors
* {@link #confirmPayment(UUID, UUID)} but authenticates via the
* guest token instead of {@code userId}.
*/
public Order confirmGuestPayment(UUID guestToken) {
Order order = getOrderByGuestToken(guestToken);
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
throw new InvalidOrderStateException(
"Beställningen kan inte ändras i detta tillstånd");
}
order.setStatus(OrderStatus.PROCESSING);
Order saved = orderRepository.save(order);
orderNotificationService.notifyOrderProcessing(saved);
return saved;
}
public List<Order> getOrdersByUserId(UUID userId) { public List<Order> getOrdersByUserId(UUID userId) {
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId); return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
} }

View file

@ -1,25 +0,0 @@
-- Allows orders without a registered user (guest checkout).
-- Users can place and pay for letters without creating an account.
--
-- user_id: previously NOT NULL - drop the constraint so guest orders
-- can be created without a registered user. The FK stays in
-- place (NULL user_id is FK-legal).
-- guest_email: contact address for the guest. Used to send the magic
-- link that lets them revisit their order status.
-- guest_token: opaque UUID v4 - the only credential a guest has. Acts
-- as their session token for order lookup + payment confirm.
ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE orders ADD COLUMN guest_email VARCHAR(255);
ALTER TABLE orders ADD COLUMN guest_token UUID;
-- Unique index on guest_token. Both H2 (tests/dev) and PostgreSQL (prod)
-- treat NULLs as distinct in a UNIQUE index, so user-owned orders (which
-- have a NULL token) never collide, while non-NULL guest tokens are
-- enforced unique. A plain index is used instead of a partial
-- (WHERE guest_token IS NOT NULL) index because H2 does not support
-- partial indexes, and the plain form preserves the intended semantics.
CREATE UNIQUE INDEX idx_orders_guest_token
ON orders(guest_token);
CREATE INDEX idx_orders_guest_email
ON orders(guest_email);

View file

@ -42,18 +42,16 @@ class AdminControllerTest {
private AdminOrderWorkflowService adminOrderWorkflowService; private AdminOrderWorkflowService adminOrderWorkflowService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@WithMockUser(username = "test@bilhej.se", roles = "USER") @WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception { void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test

View file

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

View file

@ -1,174 +0,0 @@
package se.bilhalsning.controller;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class GuestOrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
// --- POST /api/guest-orders (create) ---
@Test
void shouldCreateGuestOrderWithoutAuth() throws Exception {
UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID guestToken = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order savedOrder = new Order();
savedOrder.setId(orderId);
savedOrder.setPlate("ABC123");
savedOrder.setLetterText("Hej fin bil!");
savedOrder.setStatus(OrderStatus.PENDING_PAYMENT);
savedOrder.setGuestEmail("guest@example.com");
savedOrder.setGuestToken(guestToken);
when(orderService.createGuestOrder("ABC123", "Hej fin bil!", "guest@example.com"))
.thenReturn(savedOrder);
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej fin bil!\",\"email\":\"guest@example.com\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.letterText").value("Hej fin bil!"))
.andExpect(jsonPath("$.status").value("pending_payment"))
.andExpect(jsonPath("$.guestToken").value(guestToken.toString()));
}
@Test
void shouldRejectInvalidPlateFormat() throws Exception {
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"INVALID\",\"letterText\":\"Hej\",\"email\":\"guest@example.com\"}"))
.andExpect(status().isBadRequest());
}
@Test
void shouldRejectBlankEmail() throws Exception {
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\",\"email\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
void shouldRejectInvalidEmail() throws Exception {
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\",\"email\":\"not-an-email\"}"))
.andExpect(status().isBadRequest());
}
@Test
void shouldRejectBlankLetterText() throws Exception {
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"\",\"email\":\"guest@example.com\"}"))
.andExpect(status().isBadRequest());
}
// --- GET /api/guest-orders/{token} ---
@Test
void shouldGetGuestOrderByToken() throws Exception {
UUID token = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order();
order.setId(orderId);
order.setPlate("ABC123");
order.setLetterText("Hej bil!");
order.setStatus(OrderStatus.PROCESSING);
order.setGuestToken(token);
when(orderService.getOrderByGuestToken(token)).thenReturn(order);
mockMvc.perform(get("/api/guest-orders/{token}", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.status").value("processing"))
.andExpect(jsonPath("$.guestToken").value(token.toString()));
}
@Test
void shouldReturn404WhenGuestOrderTokenNotFound() throws Exception {
UUID token = UUID.randomUUID();
when(orderService.getOrderByGuestToken(token))
.thenThrow(new OrderNotFoundException(token));
mockMvc.perform(get("/api/guest-orders/{token}", token))
.andExpect(status().isNotFound());
}
// --- POST /api/guest-orders/{token}/pay ---
@Test
void shouldConfirmGuestPayment() throws Exception {
UUID token = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order();
order.setId(orderId);
order.setPlate("ABC123");
order.setLetterText("Hej bil!");
order.setStatus(OrderStatus.PROCESSING);
order.setGuestToken(token);
when(orderService.confirmGuestPayment(token)).thenReturn(order);
mockMvc.perform(post("/api/guest-orders/{token}/pay", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("processing"));
}
@Test
void shouldReturn409WhenPaymentAlreadyConfirmed() throws Exception {
UUID token = UUID.randomUUID();
when(orderService.confirmGuestPayment(token))
.thenThrow(new InvalidOrderStateException("Beställningen kan inte ändras i detta tillstånd"));
mockMvc.perform(post("/api/guest-orders/{token}/pay", token))
.andExpect(status().isConflict());
}
@Test
void shouldReturn404WhenPayingWithUnknownToken() throws Exception {
UUID token = UUID.randomUUID();
when(orderService.confirmGuestPayment(token))
.thenThrow(new OrderNotFoundException(token));
mockMvc.perform(post("/api/guest-orders/{token}/pay", token))
.andExpect(status().isNotFound());
}
}

View file

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

View file

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

View file

@ -253,122 +253,4 @@ class OrderServiceTest {
() -> orderService.confirmPayment(orderId, otherUserId)); () -> orderService.confirmPayment(orderId, otherUserId));
} }
// --- Guest order: createGuestOrder ---
@Test
void shouldCreateGuestOrderWithPendingPaymentStatus() {
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createGuestOrder("ABC123", "Hej fin bil!", "guest@example.com");
assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus());
assertEquals("ABC123", result.getPlate());
assertEquals("Hej fin bil!", result.getLetterText());
assertEquals("guest@example.com", result.getGuestEmail());
assertNull(result.getUserId());
}
@Test
void shouldNormalizeGuestPlateToUppercaseAndTrim() {
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createGuestOrder(" abc123 ", "Test", "guest@example.com");
assertEquals("ABC123", result.getPlate());
}
@Test
void shouldNormalizeGuestEmailToLowercaseAndTrim() {
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createGuestOrder("ABC123", "Test", " Guest@EXAMPLE.COM ");
assertEquals("guest@example.com", result.getGuestEmail());
}
@Test
void shouldHandleNullGuestEmail() {
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createGuestOrder("ABC123", "Test", null);
assertNull(result.getGuestEmail());
}
// --- Guest order: getOrderByGuestToken ---
@Test
void shouldGetGuestOrderByToken() {
UUID token = UUID.randomUUID();
Order order = new Order();
order.setGuestToken(token);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
Order result = orderService.getOrderByGuestToken(token);
assertSame(order, result);
}
@Test
void shouldThrowWhenGuestTokenNotFound() {
UUID token = UUID.randomUUID();
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class,
() -> orderService.getOrderByGuestToken(token));
}
@Test
void shouldThrowWhenGuestTokenPointsToUserOwnedOrder() {
UUID token = UUID.randomUUID();
Order order = new Order();
order.setGuestToken(token);
order.setUserId(UUID.randomUUID()); // security: user-owned order must not be exposed
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
assertThrows(OrderNotFoundException.class,
() -> orderService.getOrderByGuestToken(token));
}
// --- Guest order: confirmGuestPayment ---
@Test
void shouldConfirmGuestPaymentForPendingOrder() {
UUID token = UUID.randomUUID();
Order order = new Order();
order.setGuestToken(token);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.confirmGuestPayment(token);
assertEquals(OrderStatus.PROCESSING, result.getStatus());
verify(orderNotificationService).notifyOrderProcessing(result);
}
@Test
void shouldThrowWhenConfirmingGuestPaymentForNonPendingOrder() {
UUID token = UUID.randomUUID();
Order order = new Order();
order.setGuestToken(token);
order.setStatus(OrderStatus.CANCELLED);
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
assertThrows(InvalidOrderStateException.class,
() -> orderService.confirmGuestPayment(token));
verify(orderRepository, never()).save(any(Order.class));
verify(orderNotificationService, never()).notifyOrderProcessing(any());
}
@Test
void shouldThrowWhenConfirmingGuestPaymentForUnknownToken() {
UUID token = UUID.randomUUID();
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class,
() -> orderService.confirmGuestPayment(token));
}
} }

View file

@ -1,106 +0,0 @@
# Bindless dev stack — standalone variant of docker-compose.yml.
#
# Why this exists as a standalone file (not an override):
# Docker Compose merges `volumes:` by list concatenation, not by entry
# replacement, so an override can't drop the bind mounts from the base file —
# only append to them. A standalone file lets us redefine services with only
# the volumes we want.
#
# Usage:
# docker compose -f docker-compose.dev-bindless.yml up -d --build
#
# Use this when the Docker daemon can't bind-mount the host repo correctly:
# - Docker-in-Docker setups (e.g. this Hermes sandbox)
# - rootless Docker with restricted mount paths
# - Some CI runners
#
# For normal local dev, use docker-compose.yml — it bind-mounts the repo for
# Vite HMR and gradle bootRun hot reload.
#
# Trade-off vs. the bind-mounted dev compose:
# - The image is "frozen" at build time. Editing source on the host does not
# affect the running container. Edit + rebuild + restart, or run
# `docker compose up -d --build` after changes.
# - All source lives inside the image (docker/backend.Dockerfile and
# docker/frontend.Dockerfile COPY it in at build time).
#
# What you still get:
# - Gradle caches in named volumes (.gradle, backend/build, gradle-cache)
# so dependency downloads persist between `up` cycles.
# - Postgres data persists across `down` (via the pgdata volume).
services:
postgres:
image: postgres:16
container_name: bilhej-postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
mailpit:
image: ghcr.io/axllent/mailpit:v1.28
container_name: bilhej-mailpit
ports:
- "1025:1025"
- "8025:8025"
backend:
image: bilhej-backend-dev
build:
dockerfile: docker/backend.Dockerfile
context: .
container_name: bilhej-backend
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: docker
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
SWISH_NUMBER: ${SWISH_NUMBER}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost:3000}
MAIL_HOST: mailpit
MAIL_PORT: "1025"
MAIL_USERNAME: ""
MAIL_PASSWORD: ""
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
depends_on:
postgres:
condition: service_healthy
mailpit:
condition: service_started
volumes:
- backend-gradle-project:/app/.gradle
- backend-build:/app/backend/build
- gradle-cache:/root/.gradle
frontend:
image: bilhej-frontend-dev
build:
dockerfile: docker/frontend.Dockerfile
context: .
container_name: bilhej-frontend
ports:
- "3000:3000"
depends_on:
- backend
volumes:
pgdata:
gradle-cache:
backend-gradle-project:
backend-build:

View file

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

View file

@ -49,9 +49,6 @@ services:
build: build:
dockerfile: docker/frontend.prod.Dockerfile dockerfile: docker/frontend.prod.Dockerfile
context: . context: .
args:
VITE_UMAMI_WEBSITE_ID: ${VITE_UMAMI_WEBSITE_ID:-}
VITE_UMAMI_SCRIPT_URL: ${VITE_UMAMI_SCRIPT_URL:-https://analytics.bilhej.se/script.js}
container_name: bilhej-frontend-prod container_name: bilhej-frontend-prod
ports: ports:
- "3001:80" - "3001:80"

View file

@ -1,15 +1,3 @@
FROM eclipse-temurin:21-jdk FROM eclipse-temurin:21-jdk
WORKDIR /app WORKDIR /app
# Copy build configuration and wrapper first so this layer caches well.
COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
RUN chmod +x gradlew
# Copy backend module. The dev compose overlays this with a host bind mount
# for live source changes; if the bind mount is absent (DinD, CI, k8s) the
# image is still self-contained and `gradlew :backend:bootRun` will work.
COPY backend/ backend/
EXPOSE 8080
ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"] ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"]

View file

@ -1,16 +1,7 @@
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app
# Install dependencies first so this layer caches independently of source changes.
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm install RUN npm install
# Copy the rest of the frontend. The dev compose overlays individual paths
# (./frontend/src, ./frontend/public, ./frontend/index.html) with host bind
# mounts for live reload; if those bind mounts are absent (DinD, CI, k8s)
# the image is still self-contained and `npm run dev` will serve from the
# COPY'd files.
COPY frontend/ . COPY frontend/ .
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View file

@ -1,9 +1,5 @@
FROM node:24-alpine AS builder FROM node:24-alpine AS builder
WORKDIR /app WORKDIR /app
ARG VITE_UMAMI_WEBSITE_ID=
ARG VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
ENV VITE_UMAMI_WEBSITE_ID=$VITE_UMAMI_WEBSITE_ID
ENV VITE_UMAMI_SCRIPT_URL=$VITE_UMAMI_SCRIPT_URL
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
COPY frontend/ . COPY frontend/ .

View file

@ -1,7 +1,7 @@
server { server {
listen 80; listen 80;
listen 443 ssl; listen 443 ssl;
server_name bilhej.se www.bilhej.se; server_name _;
ssl_certificate /etc/nginx/certs/cert.crt; ssl_certificate /etc/nginx/certs/cert.crt;
ssl_certificate_key /etc/nginx/certs/cert.key; ssl_certificate_key /etc/nginx/certs/cert.key;
@ -11,59 +11,20 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# ── Security headers ──────────────────────────────────────────
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Strict-Transport-Security only when HTTPS is active
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# ── Gzip ──────────────────────────────────────────────────────
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml
application/xml text/javascript image/svg+xml text/markdown;
gzip_vary on;
gzip_min_length 256;
gzip_comp_level 6;
# ── API reverse proxy ─────────────────────────────────────────
location /api/ { location /api/ {
proxy_pass http://backend:8080; proxy_pass http://backend:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
} }
# ── Static assets with far-future cache ───────────────────────
location ~* \.(?:ico|svg|css|js|woff2?|ttf|eot|png|jpg|jpeg|gif|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
try_files $uri =404;
}
# ── Robots + sitemap (never cached, fresh every time) ─────────
location = /robots.txt {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
location = /sitemap.xml {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
# ── SPA fallback — serve index.html for all other routes ──────
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# ── Deny access to dotfiles ─────────────────────────────────── gzip on;
location ~ /\. { gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
deny all; gzip_vary on;
return 404; gzip_min_length 256;
}
} }

View file

@ -1,81 +0,0 @@
# Umami analytics (BilHej app)
Privacy-friendly page analytics via self-hosted [Umami](https://umami.is/docs) on **`https://analytics.bilhej.se`**. Server install is live on the VPS; this doc is for **BilHej code and deploy config** only.
## Values (production)
| Item | Value |
|------|--------|
| Collector | `https://analytics.bilhej.se` |
| Tracker script | `https://analytics.bilhej.se/script.js` |
| Dashboard | `https://analytics.bilhej.se` (admin login) |
| Website in Umami | Name **BilHej**, domain **`bilhej.se`** |
| Website ID | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
The Website ID is public in the browser (tracking snippet). Set it via **`VITE_UMAMI_WEBSITE_ID`** in production frontend build env — do not hardcode in source.
**Note:** Umami 3.1 on this server uses the default **`/script.js`** path. `TRACKER_SCRIPT_NAME` / custom `bilhej-stats.js` is not applied in this version.
### Example snippet (for reference)
```html
<script
defer
src="https://analytics.bilhej.se/script.js"
data-website-id="ce59614c-9f2a-4f99-8ba3-c5217f88c3f7"
></script>
```
## Frontend env
Production builds read this from the **Forgejo Actions secret** `VITE_UMAMI_WEBSITE_ID`. The deploy workflow writes it into `.env` on the server, then `docker compose -f docker-compose.prod.yml build` bakes it into the frontend image.
**Forgejo → Repository → Settings → Actions → Secrets:**
| Secret | Value |
|--------|--------|
| `VITE_UMAMI_WEBSITE_ID` | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
Not a high-risk secret (the same ID appears in the browser), but keeping it in Forgejo matches other deploy config.
Manual deploy on the server (without Forgejo) works the same way: put the line in the project `.env` before `docker compose ... up --build`.
Optional override (default matches production):
```bash
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
```
Leave `VITE_UMAMI_WEBSITE_ID` unset in local dev unless you intentionally send traffic to production Umami.
## Implementation checklist
- [x] Load `script.js` with `data-website-id` from `VITE_UMAMI_WEBSITE_ID` (only when set).
- [x] Send **SPA pageviews** on Vue Router `afterEach` (`data-auto-track="false"`).
- [x] Update **integritetspolicy** — analytics, country-level stats, no IP stored in BilHej DB.
- [x] Admin link **Webbstatistik** → Umami dashboard (prod builds only).
Umami derives **country** from the visitor IP at ingest and does not show IP lists in the UI. BilHej must not store visitor IPs for analytics.
## Verify after deploy
1. Browse `https://bilhej.se` (several routes).
2. Umami → **BilHej** → **Realtime** / **Countries**.
## Server layout (reference)
| Item | Actual on VPS |
|------|----------------|
| Compose project | `~/umami` (`/home/jocke/umami`) |
| Public access | nginx → `http://umami:3000` on Docker network `web` (host port 3000 used by open-webui) |
| Database | `umami-db` on internal network `umami-internal` only |
```bash
cd ~/umami
docker compose ps
docker compose logs -f umami
docker compose pull && docker compose up -d # updates — read release notes first
docker exec umami-db pg_dump -U umami umami > ~/umami-backup-$(date +%F).sql
```
Country stats require nginx to pass **`X-Forwarded-For`** (already configured for this vhost).

View file

@ -70,13 +70,6 @@ test.describe('Auth guards', () => {
}) })
test('allows admin user to access /admin', async ({ page }) => { test('allows admin user to access /admin', async ({ page }) => {
await page.route('**/api/admin/orders', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
}),
)
const jwt = makeJwt({ role: 'admin' }) const jwt = makeJwt({ role: 'admin' })
await page.goto('/') await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)

View file

@ -1,55 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Expired token logout', () => {
test('router guard redirects expired token to login and logs out', async ({
page,
}) => {
const past = Math.floor(Date.now() / 1000) - 3600
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/orders')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
const header = page.locator('header')
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
expect(stored).toBeNull()
})
test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({
page,
}) => {
const future = Math.floor(Date.now() / 1000) + 3600
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/orders')
await page.waitForURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
const header = page.locator('header')
await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible()
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
expect(stored).toBeNull()
})
})
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

@ -100,13 +100,6 @@ test.describe('Header auth state', () => {
}) })
test('logout redirects to home page', async ({ page }) => { test('logout redirects to home page', async ({ page }) => {
await page.route('**/api/orders', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
}),
)
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' }) const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/orders') await page.goto('/orders')
await page.evaluate( await page.evaluate(

View file

@ -49,31 +49,6 @@ test.describe('Payment redirect', () => {
await page.waitForURL(/\/betalning\//) await page.waitForURL(/\/betalning\//)
await expect(page.getByText('Swisha till')).toBeVisible() await expect(page.getByText('Swisha till')).toBeVisible()
await expect( await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
page.getByRole('button', { name: 'Jag har betalat' }),
).toBeVisible()
})
test('shows QR code for desktop scanning', async ({ page }) => {
await page.goto('/compose?plate=QRA222')
await page.getByLabel('Ditt meddelande').fill('Fin bil!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
await expect(page.getByRole('img', { name: 'Swish QR-kod' })).toBeVisible()
await expect(page.getByText('Skanna QR-koden')).toBeVisible()
})
test('shows Swish payment link with pre-filled data', async ({ page }) => {
await page.goto('/compose?plate=MNO345')
await page.getByLabel('Ditt meddelande').fill('Hej där!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
const swishLink = page.getByRole('link', { name: 'Betala med Swish' })
await expect(swishLink).toBeVisible()
const href = await swishLink.getAttribute('href')
expect(href).toContain('app.swish.nu')
expect(href).toContain('amt=49.00')
}) })
}) })

View file

@ -4,143 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Skicka ett brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, så postar vi det." />
<!-- Primary Meta Tags -->
<title>Bilhej — Skicka brev till fordonsägare via registreringsnummer</title>
<meta name="title" content="Bilhej — Skicka brev till fordonsägare via registreringsnummer" />
<meta name="description" content="Skicka ett fysiskt brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, betala 49 kr via Swish. Vi postar brevet åt dig med spårning." />
<meta name="keywords" content="brev till bilägare, kontakta fordonsägare, registreringsnummer, skicka brev, anonymt meddelande, parkeringsskada, köp bilen, tuta, körbeteende, svensk post" />
<meta name="author" content="Bilhej" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="language" content="Swedish" />
<meta name="theme-color" content="#1d4ed8" /> <meta name="theme-color" content="#1d4ed8" />
<link rel="canonical" href="https://bilhej.se/" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://bilhej.se/" />
<meta property="og:title" content="Bilhej — Skicka brev till fordonsägare" />
<meta property="og:description" content="Skicka ett fysiskt brev till en bilägare via registreringsnummer. Skriv, betala 49 kr, vi postar." />
<meta property="og:site_name" content="Bilhej" />
<meta property="og:locale" content="sv_SE" />
<meta property="og:image" content="https://bilhej.se/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://bilhej.se/" />
<meta name="twitter:title" content="Bilhej — Skicka brev till fordonsägare" />
<meta name="twitter:description" content="Skicka ett fysiskt brev till en bilägare via registreringsnummer. Skriv, betala 49 kr, vi postar." />
<meta name="twitter:image" content="https://bilhej.se/og-image.png" />
<!-- Performance: preconnect to font + analytics origins -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://analytics.bilhej.se" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Bilhej — Skicka brev till fordonsägare</title>
<!-- Structured Data: Organization -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Bilhej",
"url": "https://bilhej.se",
"logo": "https://bilhej.se/favicon.svg",
"description": "Tjänst för att skicka fysiska brev till fordonsägare via registreringsnummer.",
"email": "kontakt@bilhej.se",
"contactPoint": {
"@type": "ContactPoint",
"email": "support@bilhej.se",
"contactType": "customer support",
"availableLanguage": ["Swedish"]
}
}
</script>
<!-- Structured Data: WebSite with SearchAction -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Bilhej",
"url": "https://bilhej.se",
"inLanguage": "sv-SE",
"potentialAction": {
"@type": "ReadAction",
"target": "https://bilhej.se"
}
}
</script>
<!-- Structured Data: Service / Product -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Service",
"name": "Bilhej — Brev till fordonsägare",
"description": "Skicka ett fysiskt brev till en fordonsägare genom att ange registreringsnummer. Swish-betalning, spårning och anonym avsändare.",
"url": "https://bilhej.se",
"provider": {
"@type": "Organization",
"name": "Bilhej",
"url": "https://bilhej.se"
},
"areaServed": {
"@type": "Country",
"name": "Sverige"
},
"offers": {
"@type": "Offer",
"price": "49",
"priceCurrency": "SEK",
"description": "Per brev: utskrift, kuvertering och postning"
}
}
</script>
<!-- FAQ Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Hur skickar jag ett brev till en bilägare?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ange bilens registreringsnummer på bilhej.se, skriv ditt meddelande med en mall eller fritext, betala 49 kr via Swish, så postar vi brevet åt dig."
}
},
{
"@type": "Question",
"name": "Behöver jag veta vem som äger bilen?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Nej, du behöver bara registreringsnumret. Vi kopplar brevet till rätt mottagare. Du ser aldrig mottagarens namn eller adress."
}
},
{
"@type": "Question",
"name": "Kan jag vara anonym?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ja, du kan nå bilägaren anonymt. Lägg bara till dina kontaktuppgifter i brevet om du vill att de ska kunna svara."
}
},
{
"@type": "Question",
"name": "Vad kostar det?",
"acceptedAnswer": {
"@type": "Answer",
"text": "49 kr per brev. Detta täcker utskrift, kuvertering och porto. Du betalar via Swish."
}
}
]
}
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -9,7 +9,6 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
@ -17,7 +16,6 @@
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
@ -793,6 +791,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -810,6 +811,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -827,6 +831,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -844,6 +851,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -861,6 +871,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -878,6 +891,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1038,16 +1054,6 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1", "version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
@ -1956,15 +1962,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -1990,91 +1987,11 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -2087,6 +2004,7 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@ -2218,15 +2136,6 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -2251,12 +2160,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2815,15 +2718,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@ -2969,6 +2863,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -3390,6 +3285,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3411,6 +3309,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3432,6 +3333,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3453,6 +3357,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3836,15 +3743,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -3889,6 +3787,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -4037,15 +3936,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.13", "version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
@ -4144,23 +4034,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -4211,15 +4084,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -4230,12 +4094,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@ -4350,12 +4208,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5242,12 +5094,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -5390,12 +5236,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.3", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
@ -5411,134 +5251,6 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

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

View file

@ -5,14 +5,7 @@ const isCI = !!process.env.PLAYWRIGHT_BASE_URL
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
timeout: 30_000, timeout: 30_000,
// CI flakes: the E2E stack runs 4 parallel Playwright workers against a retries: 0,
// single backend (Spring Boot, no -Xmx cap). Under load an occasional
// order-creation request transiently fails, which surfaces as a spurious
// "navigation to /betalning/ timed out" failure unrelated to the code under
// test (e.g. run 103, where 2 navigation tests failed while 94/94 passed
// locally on the same commit). Per Playwright's guidance, retry transient
// failures in CI; keep retries off locally for fast feedback.
retries: isCI ? 2 : 0,
use: { use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
headless: true, headless: true,

View file

@ -1,27 +0,0 @@
# Allow all crawlers
User-agent: *
Crawl-delay: 10
Disallow: /api/
Disallow: /admin/
Disallow: /orders/
Disallow: /bestallning/*/redigera
Disallow: /betalning/*
Disallow: /gast-bestallning*
Disallow: /gast-betalning/*
Disallow: /gast-order/*
Disallow: /andra-losenord
Disallow: /andra-epost
Disallow: /bekrafta-epost
Disallow: /aterstall-losenord
Disallow: /glomt-losenord
# Allow important pages
Allow: /
Allow: /om-oss
Allow: /om
Allow: /kontakt
Allow: /integritetspolicy
Allow: /villkor
# Sitemap
Sitemap: https://bilhej.se/sitemap.xml

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://bilhej.se/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://bilhej.se/om-oss</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://bilhej.se/om</loc>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://bilhej.se/kontakt</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://bilhej.se/integritetspolicy</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://bilhej.se/villkor</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>

View file

@ -1,29 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue' import AppFooter from '@/components/AppFooter.vue'
import { useSeo } from '@/composables/useSeo'
import { routeSeo } from '@/data/routeSeo'
const route = useRoute()
const seo = useSeo()
// Update title and meta tags on every route change
watch(
() => route.name,
(name) => {
const meta = routeSeo[name as string]
if (meta) {
seo.set(meta)
} else {
// Fallback for unnamed or dynamic routes (payment, guest, etc.)
seo.reset()
}
},
{ immediate: true },
)
</script> </script>
<template> <template>

View file

@ -1,185 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
const mocks = vi.hoisted(() => ({
mockCreateGuestOrder: vi.fn(),
}))
vi.mock('@/api/guestOrders', () => ({
createGuestOrder: mocks.mockCreateGuestOrder,
}))
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
import { createGuestOrder } from '@/api/guestOrders'
const mockCreateGuestOrder = vi.mocked(createGuestOrder)
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/gast-kassa',
name: 'guest-checkout',
component: GuestCheckoutPage,
},
{
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: { template: '<div>Payment</div>' },
},
{
path: '/logga-in',
name: 'login',
component: { template: '<div>Login</div>' },
},
],
})
}
async function mountPage() {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'guest-checkout' })
await router.isReady()
const wrapper = mount(GuestCheckoutPage, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
describe('GuestCheckoutPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders heading and price', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Skicka ett brev')
expect(wrapper.text()).toContain('49 kr')
expect(wrapper.text()).toContain('Inget konto behövs')
})
it('disables submit button when fields are empty', async () => {
const { wrapper } = await mountPage()
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeDefined()
})
it('enables submit button when plate, letter, and email are valid', async () => {
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej, din bil står fel!')
await inputs[1].setValue('test@example.se')
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeUndefined()
})
it('keeps submit disabled with an invalid plate', async () => {
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC') // too short
await textarea.setValue('Hej')
await inputs[1].setValue('test@example.se')
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeDefined()
})
it('keeps submit disabled with an invalid email', async () => {
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej')
await inputs[1].setValue('not-an-email')
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeDefined()
})
it('shows character count that updates with input', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Hej')
expect(wrapper.text()).toContain('3 / 1000 tecken')
})
it('shows link to login page', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Har du redan ett konto?')
expect(wrapper.text()).toContain('Logga in')
})
it('calls createGuestOrder and navigates on submit', async () => {
mockCreateGuestOrder.mockResolvedValue({
id: 'order-123',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
})
const { wrapper, router } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej, din bil står fel!')
await inputs[1].setValue('test@example.se')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(mockCreateGuestOrder).toHaveBeenCalledWith(
'ABC123',
'Hej, din bil står fel!',
'test@example.se',
)
})
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-payment')
expect(router.currentRoute.value.params.orderId).toBe('order-123')
expect(router.currentRoute.value.query.token).toBe('token-abc')
expect(router.currentRoute.value.query.plate).toBe('ABC123')
})
})
it('shows error message when order creation fails', async () => {
mockCreateGuestOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej')
await inputs[1].setValue('test@example.se')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte skapa beställningen. Försök igen senare.',
)
})
})
})

View file

@ -1,191 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
const mocks = vi.hoisted(() => ({
mockFetchGuestOrder: vi.fn(),
}))
vi.mock('@/api/guestOrders', () => ({
fetchGuestOrder: mocks.mockFetchGuestOrder,
}))
import GuestOrderPage from '@/pages/GuestOrderPage.vue'
const mockOrder = {
id: 'order-123',
plate: 'ABC123',
letterText: 'Hej, din bil står fel!',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T12:00:00Z',
guestToken: 'token-abc',
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/gast-order/:token',
name: 'guest-order',
component: GuestOrderPage,
},
{
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: { template: '<div>Payment</div>' },
},
],
})
}
async function mountPage(token = 'token-abc') {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'guest-order', params: { token } })
await router.isReady()
const wrapper = mount(GuestOrderPage, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
describe('GuestOrderPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
})
it('shows loading state initially', async () => {
// Never resolve so we can see the loading state
mocks.mockFetchGuestOrder.mockReturnValue(new Promise(() => {}))
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Laddar')
})
it('renders order details after loading', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Din beställning')
})
expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('order-123')
expect(wrapper.text()).toContain('Hej, din bil står fel!')
})
it('displays human-readable status labels', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'pending_payment',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Väntar på betalning')
})
})
it('shows "Behandlas" for paid status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'paid',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Behandlas')
})
})
it('shows "Skickat" for sent status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'sent',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Skickat')
})
})
it('shows "Levererat" for delivered status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'delivered',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Levererat')
})
})
it('shows "Misslyckades" for failed status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'failed',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Misslyckades')
})
})
it('shows "Avbrutet" for cancelled status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'cancelled',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbrutet')
})
})
it('shows payment link when status is pending_payment', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Gå till betalningssidan')
})
})
it('hides payment link when order is not pending_payment', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'sent',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).not.toContain('Gå till betalningssidan')
})
})
it('shows error when fetch fails', async () => {
mocks.mockFetchGuestOrder.mockRejectedValue(new Error('Not found'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte hitta beställningen. Kontrollera länken.',
)
})
})
it('does not fetch order when token is missing in params', async () => {
// The component guards against an empty token in onMounted.
// Vue Router requires the :token param so we simulate by checking
// that fetchGuestOrder is only called once per mount with a token.
const { wrapper } = await mountPage('token-abc')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Din beställning')
})
expect(mocks.mockFetchGuestOrder).toHaveBeenCalledTimes(1)
})
})

View file

@ -1,307 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
const mocks = vi.hoisted(() => ({
mockFetchGuestOrder: vi.fn(),
mockPayGuestOrder: vi.fn(),
mockFetchSwishInfo: vi.fn(),
mockBuildSwishPaymentUrl: vi.fn(),
mockToDataURL: vi.fn(),
}))
vi.mock('@/api/guestOrders', () => ({
fetchGuestOrder: mocks.mockFetchGuestOrder,
payGuestOrder: mocks.mockPayGuestOrder,
}))
vi.mock('@/api/payment', () => ({
fetchSwishInfo: mocks.mockFetchSwishInfo,
buildSwishPaymentUrl: mocks.mockBuildSwishPaymentUrl,
}))
vi.mock('qrcode', () => ({
default: {
toDataURL: mocks.mockToDataURL,
},
}))
import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue'
import QRCode from 'qrcode'
const mockToDataURL = vi.mocked(QRCode.toDataURL)
const mockOrder = {
id: 'order-123',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: GuestPaymentRedirect,
},
{
path: '/gast-order/:token',
name: 'guest-order',
component: { template: '<div>Order</div>' },
},
],
})
}
async function mountPage(
orderId = 'order-123',
token = 'token-abc',
plate = 'ABC123',
) {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({
name: 'guest-payment',
params: { orderId },
query: { token, plate },
})
await router.isReady()
const wrapper = mount(GuestPaymentRedirect, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
function setupDefaultMocks() {
mocks.mockFetchSwishInfo.mockResolvedValue({
number: '0701234567',
amount: 49,
})
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
mocks.mockBuildSwishPaymentUrl.mockReturnValue(
'https://app.swish.nu/1/p/sw/?sw=46701234567&amt=49.00&msg=order-123',
)
mocks.mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
}
describe('GuestPaymentRedirect', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('renders heading and plate', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Betalning')
})
expect(wrapper.text()).toContain('ABC123')
})
it('displays order id', async () => {
const { wrapper } = await mountPage('order-456')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('order-456')
})
})
it('shows the amount to pay', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('49 kr')
})
})
it('renders QR code after loading swish info', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
})
expect(mockToDataURL).toHaveBeenCalledTimes(1)
})
it('renders Swish payment link', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
const link = wrapper.find('.payment__swish-link')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('app.swish.nu')
})
})
it('displays Swish number from API', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('0701234567')
})
})
it('shows the "Jag har betalat" button', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
expect(wrapper.text()).toContain('Jag har betalat')
})
})
it('shows confirmation dialog when clicking "Jag har betalat"', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567')
})
})
it('can cancel the confirmation dialog', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm-cancel').exists()).toBe(true)
})
await wrapper.find('.payment__confirm-cancel').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag har betalat')
expect(wrapper.text()).not.toContain('Jag bekräftar')
})
})
it('calls payGuestOrder on confirmation', async () => {
mocks.mockPayGuestOrder.mockResolvedValue({
...mockOrder,
status: 'processing',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
true,
)
})
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
expect(mocks.mockPayGuestOrder).toHaveBeenCalledWith('token-abc')
})
it('navigates to guest-order after successful payment', async () => {
mocks.mockPayGuestOrder.mockResolvedValue({
...mockOrder,
status: 'processing',
})
const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
true,
)
})
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-order')
expect(router.currentRoute.value.params.token).toBe('token-abc')
})
})
it('shows error when payment confirmation fails', async () => {
mocks.mockPayGuestOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
true,
)
})
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte bekräfta betalningen. Försök igen.',
)
})
})
it('shows error when swish info fetch fails', async () => {
mocks.mockFetchSwishInfo.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte ladda betalningsinformation. Försök igen senare.',
)
})
})
it('shows error when token is missing', async () => {
const { wrapper } = await mountPage('order-123', '', 'ABC123')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Saknar order-token. Gå tillbaka och försök igen.',
)
})
expect(mocks.mockFetchSwishInfo).not.toHaveBeenCalled()
})
it('redirects to guest-order when order is already paid', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'processing',
})
const { router } = await mountPage()
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-order')
expect(router.currentRoute.value.params.token).toBe('token-abc')
})
})
it('shows the magic order link', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Din orderlänk')
})
expect(wrapper.find('.guest-payment__magic-url').text()).toContain(
'token-abc',
)
})
})

View file

@ -1,10 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import HomePage from '@/pages/HomePage.vue' import HomePage from '@/pages/HomePage.vue'
import ComposePage from '@/pages/ComposePage.vue' import ComposePage from '@/pages/ComposePage.vue'
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
vi.mock('@/api/vehicles', () => ({ vi.mock('@/api/vehicles', () => ({
lookupVehicle: vi.fn(), lookupVehicle: vi.fn(),
@ -19,11 +17,6 @@ function createTestRouter() {
routes: [ routes: [
{ path: '/', name: 'home', component: HomePage }, { path: '/', name: 'home', component: HomePage },
{ path: '/compose', name: 'compose', component: ComposePage }, { path: '/compose', name: 'compose', component: ComposePage },
{
path: '/gast-bestallning',
name: 'guest-checkout',
component: GuestCheckoutPage,
},
], ],
}) })
} }
@ -35,12 +28,6 @@ function mountHome(router: ReturnType<typeof createTestRouter>) {
} }
describe('HomePage', () => { describe('HomePage', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.removeItem('auth_token')
vi.clearAllMocks()
})
it('renders headline', () => { it('renders headline', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mountHome(router) const wrapper = mountHome(router)
@ -101,7 +88,7 @@ describe('HomePage', () => {
expect(cta.text()).toBe('Fortsätt till brevet') expect(cta.text()).toBe('Fortsätt till brevet')
}) })
it('CTA links to guest checkout with plate query param when unauthenticated', async () => { it('CTA links to compose page with plate query param', async () => {
mockLookupVehicle.mockResolvedValue({ mockLookupVehicle.mockResolvedValue({
make: 'Volvo', make: 'Volvo',
model: 'V70', model: 'V70',
@ -120,6 +107,6 @@ describe('HomePage', () => {
const cta = wrapper.find('.btn--primary') const cta = wrapper.find('.btn--primary')
const href = cta.attributes('href') const href = cta.attributes('href')
expect(href).toBe('/gast-bestallning?plate=ABC123') expect(href).toBe('/compose?plate=ABC123')
}) })
}) })

View file

@ -4,26 +4,6 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
const sessionMocks = vi.hoisted(() => {
const mockLogout = vi.fn()
const mockPush = vi.fn()
return {
mockLogout,
mockPush,
mockAuth: { isAuthenticated: true, logout: mockLogout },
}
})
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => sessionMocks.mockAuth,
}))
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { fullPath: '/orders' } },
push: sessionMocks.mockPush,
},
}))
function mockFetchResponse(status: number, body: unknown) { function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({ return Promise.resolve({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
@ -396,34 +376,3 @@ describe('OrdersPage', () => {
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false) expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
}) })
}) })
describe('OrdersPage — expired session (401)', () => {
beforeEach(() => {
localStorage.clear()
globalThis.fetch = vi.fn()
sessionMocks.mockLogout.mockClear()
sessionMocks.mockPush.mockClear()
sessionMocks.mockAuth.isAuthenticated = true
})
it('does not show generic error and triggers global logout/redirect on 401', async () => {
vi.mocked(globalThis.fetch).mockImplementation((url) => {
const urlStr = String(url)
if (urlStr.includes('/payment/swish-info')) {
return mockFetchResponse(200, { number: '123', amount: 49 })
}
return mockFetchResponse(401, { message: 'Din session har löpt ut.' })
})
localStorage.setItem('auth_token', 'expired-token')
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Kunde inte hämta beställningar')
expect(sessionMocks.mockLogout).toHaveBeenCalledTimes(1)
expect(sessionMocks.mockPush).toHaveBeenCalledWith({
name: 'login',
query: { redirect: '/orders' },
})
})
})

View file

@ -5,26 +5,14 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import PaymentRedirect from '@/pages/PaymentRedirect.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,mock-qr'),
},
}))
vi.mock('@/api/payment', () => ({ vi.mock('@/api/payment', () => ({
payOrder: vi.fn(), payOrder: vi.fn(),
fetchSwishInfo: vi.fn(), fetchSwishInfo: vi.fn(),
buildSwishPaymentUrl: vi.fn(
(number: string, amount: number, message: string) =>
`https://app.swish.nu/1/p/sw/?sw=${number}&amt=${amount.toFixed(2)}&msg=${message}`,
),
})) }))
import { payOrder, fetchSwishInfo } from '@/api/payment' import { payOrder, fetchSwishInfo } from '@/api/payment'
import QRCode from 'qrcode'
const mockPayOrder = vi.mocked(payOrder) const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo) const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
const mockToDataURL = vi.mocked(QRCode.toDataURL)
function createTestRouter() { function createTestRouter() {
return createRouter({ return createRouter({
@ -71,7 +59,6 @@ describe('PaymentRedirect', () => {
number: '0701234567', number: '0701234567',
amount: 49, amount: 49,
}) })
mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
}) })
it('renders heading and amount', async () => { it('renders heading and amount', async () => {
@ -94,7 +81,7 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain('Beställnings-ID')
expect(wrapper.text()).toContain(orderId) expect(wrapper.text()).toContain(orderId)
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain(
'fylls i automatiskt via QR-kod eller länk', 'Ange beställnings-ID ovan som meddelande i Swish-appen',
) )
}) })
}) })
@ -106,40 +93,13 @@ describe('PaymentRedirect', () => {
}) })
}) })
it('renders QR code after fetching swish info', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
})
expect(mockToDataURL).toHaveBeenCalledTimes(1)
// Regression guard: the QR must use a spec-compliant 4-module quiet zone
// and pure black-on-white so the Swish app can scan it off a screen.
expect(mockToDataURL).toHaveBeenCalledWith(
expect.stringContaining('app.swish.nu'),
expect.objectContaining({
margin: 4,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
}),
)
})
it('renders a Swish payment link', async () => {
const { wrapper } = await mountPage('test-order', 'ABC123')
await vi.waitFor(() => {
const link = wrapper.find('.payment__swish-link')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('app.swish.nu')
})
})
it('shows confirmation dialog after clicking pay button', async () => { it('shows confirmation dialog after clicking pay button', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat') expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567') expect(wrapper.text()).toContain('0701234567')
@ -150,15 +110,15 @@ describe('PaymentRedirect', () => {
it('can cancel confirmation dialog', async () => { it('can cancel confirmation dialog', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbryt') expect(wrapper.text()).toContain('Avbryt')
}) })
await wrapper.find('.payment__confirm-cancel').trigger('click') await wrapper.find('.btn--ghost').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Swisha till') expect(wrapper.text()).toContain('Swisha till')
expect(wrapper.text()).not.toContain('Avbryt') expect(wrapper.text()).not.toContain('Avbryt')
@ -177,15 +137,16 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
await wrapper.find('.payment__confirm .btn--primary').trigger('click') const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
expect(mockPayOrder).toHaveBeenCalledWith('order-1') expect(mockPayOrder).toHaveBeenCalledWith('order-1')
}) })
@ -195,15 +156,16 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
await wrapper.find('.payment__confirm .btn--primary').trigger('click') const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen') expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
@ -222,15 +184,16 @@ describe('PaymentRedirect', () => {
const { wrapper, router } = await mountPage() const { wrapper, router } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
await wrapper.find('.payment__confirm .btn--primary').trigger('click') const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders') expect(router.currentRoute.value.name).toBe('orders')

View file

@ -41,16 +41,6 @@ describe('PrivacyPolicyPage', () => {
expect(wrapper.text()).toContain('varken vi eller obehöriga') expect(wrapper.text()).toContain('varken vi eller obehöriga')
}) })
it('describes web analytics', () => {
const router = createTestRouter()
const wrapper = mount(PrivacyPolicyPage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Webbstatistik')
expect(wrapper.text()).toContain('analytics.bilhej.se')
expect(wrapper.text()).toContain('IP-adresser')
})
it('links to contact email and contact page', () => { it('links to contact email and contact page', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mount(PrivacyPolicyPage, { const wrapper = mount(PrivacyPolicyPage, {

View file

@ -214,64 +214,6 @@ describe('Router guards', () => {
}) })
}) })
describe('Router guards — expired tokens', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('redirects expired-token user from /orders to /logga-in with redirect query', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/orders')
})
it('clears the expired token from localStorage on redirect', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/orders')
await router.isReady()
expect(localStorage.getItem('auth_token')).toBeNull()
})
it('allows access with a token whose exp is in the future', async () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('orders')
})
it('lets expired-token user open /logga-in instead of bouncing to home', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/logga-in')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
})
it('lets expired-token user open /registrera instead of bouncing to home', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/registrera')
await router.isReady()
expect(router.currentRoute.value.name).toBe('register')
})
})
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -212,52 +212,6 @@ describe('authStore', () => {
}) })
}) })
describe('authStore.isTokenExpired', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('returns true when there is no token', () => {
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(true)
})
it('returns true for a token with an expired exp claim', () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(true)
})
it('returns false for a token with a future exp claim', () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
})
it('returns false for a token without an exp claim', () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
})
it('returns true after logout clears the token', async () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
store.logout()
expect(store.isTokenExpired()).toBe(true)
})
})
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -1,125 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mocks = vi.hoisted(() => {
const mockLogout = vi.fn()
const mockPush = vi.fn()
return {
mockLogout,
mockPush,
mockAuth: { isAuthenticated: true, logout: mockLogout },
}
})
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => mocks.mockAuth,
}))
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { fullPath: '/orders' } },
push: mocks.mockPush,
},
}))
import { request, ApiError, isSessionExpired, isForbidden } from '@/api/client'
function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
})
}
describe('api client', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
globalThis.fetch = vi.fn()
mocks.mockLogout.mockClear()
mocks.mockPush.mockClear()
mocks.mockAuth.isAuthenticated = true
})
it('logs out and redirects to login on 401 from a protected endpoint', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
localStorage.setItem('auth_token', 'expired-token')
await expect(request('/orders')).rejects.toThrow('Din session har löpt ut.')
expect(mocks.mockLogout).toHaveBeenCalledTimes(1)
expect(mocks.mockPush).toHaveBeenCalledWith({
name: 'login',
query: { redirect: '/orders' },
})
})
it('still throws ApiError with 401 status after handling expired session', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
try {
await request('/orders')
throw new Error('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).status).toBe(401)
}
})
it('does not log out on 401 from an auth endpoint (wrong credentials)', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
)
await expect(request('/auth/login', { method: 'POST' })).rejects.toThrow(
'Felaktig e-post eller lösenord',
)
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('does not log out on 403 (forbidden is not session expiry)', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(403, { message: 'Du har inte behörighet' }),
)
localStorage.setItem('auth_token', 'valid-token')
await expect(request('/admin/orders')).rejects.toThrow(
'Du har inte behörighet',
)
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('does not redirect when there is no token on 401', async () => {
mocks.mockAuth.isAuthenticated = false
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
await expect(request('/orders')).rejects.toThrow()
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('isSessionExpired returns true only for a 401 ApiError', () => {
expect(isSessionExpired(new ApiError(401, 'x'))).toBe(true)
expect(isSessionExpired(new ApiError(403, 'x'))).toBe(false)
expect(isSessionExpired(new ApiError(500, 'x'))).toBe(false)
expect(isSessionExpired(new Error('x'))).toBe(false)
expect(isSessionExpired(null)).toBe(false)
})
it('isForbidden returns true only for a 403 ApiError', () => {
expect(isForbidden(new ApiError(403, 'x'))).toBe(true)
expect(isForbidden(new ApiError(401, 'x'))).toBe(false)
expect(isForbidden(new Error('x'))).toBe(false)
})
})

View file

@ -1,104 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mocks = vi.hoisted(() => ({
mockRequest: vi.fn(),
}))
vi.mock('@/api/client', () => ({
request: mocks.mockRequest,
}))
import {
createGuestOrder,
fetchGuestOrder,
payGuestOrder,
} from '@/api/guestOrders'
describe('guestOrders API', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.mockRequest.mockReset()
})
describe('createGuestOrder', () => {
it('sends a POST to /guest-orders with plate, letterText, and email', async () => {
const mockOrder = {
id: 'order-1',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
mocks.mockRequest.mockResolvedValue(mockOrder)
const result = await createGuestOrder('ABC123', 'Hej', 'test@example.se')
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders', {
method: 'POST',
body: JSON.stringify({
plate: 'ABC123',
letterText: 'Hej',
email: 'test@example.se',
}),
})
expect(result).toEqual(mockOrder)
})
it('propagates errors from the API client', async () => {
mocks.mockRequest.mockRejectedValue(new Error('Server error'))
await expect(
createGuestOrder('ABC123', 'Hej', 'test@example.se'),
).rejects.toThrow('Server error')
})
})
describe('fetchGuestOrder', () => {
it('sends a GET to /guest-orders/:token', async () => {
const mockOrder = {
id: 'order-1',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
mocks.mockRequest.mockResolvedValue(mockOrder)
const result = await fetchGuestOrder('token-abc')
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders/token-abc')
expect(result).toEqual(mockOrder)
})
})
describe('payGuestOrder', () => {
it('sends a POST to /guest-orders/:token/pay', async () => {
const mockOrder = {
id: 'order-1',
plate: 'ABC123',
letterText: 'Hej',
status: 'processing',
trackingId: null,
amountPaid: 49,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
mocks.mockRequest.mockResolvedValue(mockOrder)
const result = await payGuestOrder('token-abc')
expect(mocks.mockRequest).toHaveBeenCalledWith(
'/guest-orders/token-abc/pay',
{ method: 'POST' },
)
expect(result).toEqual(mockOrder)
})
})
})

View file

@ -1,52 +0,0 @@
import { describe, it, expect } from 'vitest'
import { buildSwishPaymentUrl } from '@/api/payment'
describe('buildSwishPaymentUrl', () => {
it('normalises Swedish national format to international', () => {
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
'sw=46701234567',
)
})
it('strips a leading + from international format', () => {
const url = buildSwishPaymentUrl('+46701234567', 49, 'test')
expect(url).toContain('sw=46701234567')
expect(url).not.toContain('sw=%2B')
expect(url).not.toContain('sw=+')
})
it('leaves already-international numbers unchanged', () => {
expect(buildSwishPaymentUrl('46701234567', 49, 'test')).toContain(
'sw=46701234567',
)
})
it('leaves Swish Business numbers (123…) unchanged', () => {
expect(buildSwishPaymentUrl('1234567890', 49, 'test')).toContain(
'sw=1234567890',
)
})
it('strips whitespace from the number', () => {
expect(buildSwishPaymentUrl('070 123 45 67', 49, 'test')).toContain(
'sw=46701234567',
)
})
it('includes the amount with two decimal places in amt', () => {
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
'amt=49.00',
)
})
it('URL-encodes the message in the msg parameter', () => {
const url = buildSwishPaymentUrl('0701234567', 49, 'ABC 123')
expect(url).toContain('msg=ABC+123')
})
it('uses the correct Swish C2B base URL', () => {
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
'https://app.swish.nu/1/p/sw/?',
)
})
})

View file

@ -1,72 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createRouter, createMemoryHistory } from 'vue-router'
import {
getUmamiConfig,
initUmamiAnalytics,
trackUmamiPageview,
} from '@/utils/umami'
describe('umami', () => {
beforeEach(() => {
document.head.innerHTML = ''
delete window.umami
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('returns null when website id is unset', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
expect(getUmamiConfig()).toBeNull()
})
it('returns config when website id is set', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '11111111-2222-3333-4444-555555555555')
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', '')
expect(getUmamiConfig()).toEqual({
websiteId: '11111111-2222-3333-4444-555555555555',
scriptUrl: 'https://analytics.bilhej.se/script.js',
})
})
it('uses custom script url when provided', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://example.test/script.js')
expect(getUmamiConfig()?.scriptUrl).toBe('https://example.test/script.js')
})
it('does not inject script when website id is unset', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
})
initUmamiAnalytics(router)
expect(document.querySelector('script[data-website-id]')).toBeNull()
})
it('injects script with auto-track disabled when configured', () => {
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
})
initUmamiAnalytics(router)
const script = document.querySelector('script[data-website-id]')
expect(script?.getAttribute('data-website-id')).toBe('test-id')
expect(script?.getAttribute('data-auto-track')).toBe('false')
expect(script?.getAttribute('src')).toContain('script.js')
})
it('trackUmamiPageview forwards url to umami', () => {
const track = vi.fn()
window.umami = { track }
trackUmamiPageview('/orders')
expect(track).toHaveBeenCalledOnce()
const mapper = track.mock.calls[0][0] as (
props: Record<string, unknown>,
) => Record<string, unknown>
expect(mapper({ referrer: 'x' })).toEqual({ referrer: 'x', url: '/orders' })
})
})

View file

@ -1,6 +1,3 @@
import { useAuthStore } from '@/stores/authStore'
import router from '@/router'
const API_BASE = import.meta.env.VITE_API_URL || '/api' const API_BASE = import.meta.env.VITE_API_URL || '/api'
export class ApiError extends Error { export class ApiError extends Error {
@ -13,27 +10,10 @@ export class ApiError extends Error {
} }
} }
export function isSessionExpired(err: unknown): boolean {
return err instanceof ApiError && err.status === 401
}
export function isForbidden(err: unknown): boolean {
return err instanceof ApiError && err.status === 403
}
function getToken(): string | null { function getToken(): string | null {
return localStorage.getItem('auth_token') return localStorage.getItem('auth_token')
} }
function handleExpiredSession(): void {
const auth = useAuthStore()
if (auth.isAuthenticated) {
auth.logout()
const redirect = router.currentRoute.value.fullPath
router.push({ name: 'login', query: { redirect } })
}
}
export async function request<T>( export async function request<T>(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
@ -54,9 +34,6 @@ export async function request<T>(
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && !url.startsWith('/auth/')) {
handleExpiredSession()
}
const body = await response.json().catch(() => ({})) const body = await response.json().catch(() => ({}))
throw new ApiError(response.status, body.message || 'Något gick fel') throw new ApiError(response.status, body.message || 'Något gick fel')
} }

View file

@ -1,39 +0,0 @@
import { request } from './client'
/**
* Guest order — placed and paid for without an account. {@link guestToken}
* is the only credential the client holds; it is returned only on create
* and from a by-token lookup. Pass it as the query string on the next
* page so a refresh keeps the session alive.
*/
export interface GuestOrder {
id: string
plate: string
letterText: string
status: string
trackingId: string | null
amountPaid: number | null
createdAt: string
guestToken: string
}
export function createGuestOrder(
plate: string,
letterText: string,
email: string,
): Promise<GuestOrder> {
return request<GuestOrder>('/guest-orders', {
method: 'POST',
body: JSON.stringify({ plate, letterText, email }),
})
}
export function fetchGuestOrder(token: string): Promise<GuestOrder> {
return request<GuestOrder>(`/guest-orders/${token}`)
}
export function payGuestOrder(token: string): Promise<GuestOrder> {
return request<GuestOrder>(`/guest-orders/${token}/pay`, {
method: 'POST',
})
}

View file

@ -15,47 +15,3 @@ export function payOrder(orderId: string): Promise<Order> {
export function fetchSwishInfo(): Promise<SwishInfo> { export function fetchSwishInfo(): Promise<SwishInfo> {
return request<SwishInfo>('/payment/swish-info') return request<SwishInfo>('/payment/swish-info')
} }
/**
* Build a pre-filled Swish payment URL.
*
* On mobile, tapping this URL opens the Swish app with the amount and
* message pre-filled. On desktop, embed it in a QR code for the user
* to scan with their phone.
*
* Uses the Swish "C2B pre-fill" URL scheme documented at
* https://developer.swish.nu — no Swish Commerce API certificate required.
* The `sw` parameter accepts either a phone number or a Swish Business
* number (123…). Phone numbers in Swedish national format (leading 0)
* are normalised to international format (46…).
*/
export function buildSwishPaymentUrl(
swishNumber: string,
amount: number,
message: string,
): string {
const payee = normalizeSwishNumber(swishNumber)
const params = new URLSearchParams({
sw: payee,
amt: amount.toFixed(2),
msg: message,
})
return `https://app.swish.nu/1/p/sw/?${params.toString()}`
}
/**
* Normalise a Swish number to the format the Swish URL expects.
* - 123… (Swish Business number) → unchanged
* - 46… (already international) → unchanged
* - 0… (Swedish national format) → 46 + rest without leading 0
* - +46… (international with plus) → 46… (the plus is stripped first)
*/
function normalizeSwishNumber(number: string): string {
// Strip whitespace and a leading "+": a number stored as "+46 70 …" would
// otherwise miss every prefix check and leak a "+" into the `sw` param.
const trimmed = number.replace(/[\s+]/g, '')
if (trimmed.startsWith('123')) return trimmed
if (trimmed.startsWith('46')) return trimmed
if (trimmed.startsWith('0')) return '46' + trimmed.slice(1)
return trimmed
}

View file

@ -1,5 +1,5 @@
import { ref, reactive, type Ref } from 'vue' import { ref, reactive, type Ref } from 'vue'
import { ApiError, isSessionExpired } from '@/api/client' import { ApiError } from '@/api/client'
import { import {
updateOrderStatus, updateOrderStatus,
registerShipment, registerShipment,
@ -69,12 +69,10 @@ export function useAdminOrderActions(
replaceOrder(updated) replaceOrder(updated)
} catch (err) { } catch (err) {
order.status = previousStatus order.status = previousStatus
if (!isSessionExpired(err)) { statusError.value =
statusError.value = err instanceof ApiError && err.message
err instanceof ApiError && err.message ? err.message
? err.message : 'Kunde inte uppdatera status. Försök igen.'
: 'Kunde inte uppdatera status. Försök igen.'
}
} }
} }
@ -102,13 +100,11 @@ export function useAdminOrderActions(
) )
replaceOrder(updated) replaceOrder(updated)
trackingInputValues[orderId] = updated.trackingId ?? trackingInput trackingInputValues[orderId] = updated.trackingId ?? trackingInput
} catch (err) { } catch {
order.status = previousStatus order.status = previousStatus
order.trackingId = previousTrackingId order.trackingId = previousTrackingId
if (!isSessionExpired(err)) { trackingError.value =
trackingError.value = 'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
}
} finally { } finally {
registeringId.value = null registeringId.value = null
} }
@ -127,12 +123,10 @@ export function useAdminOrderActions(
const updated = await updateAdminNotes(orderId, notes) const updated = await updateAdminNotes(orderId, notes)
replaceOrder(updated) replaceOrder(updated)
adminNotesValues[orderId] = updated.adminNotes ?? '' adminNotesValues[orderId] = updated.adminNotes ?? ''
} catch (err) { } catch {
order.adminNotes = previousNotes order.adminNotes = previousNotes
adminNotesValues[orderId] = previousNotes ?? '' adminNotesValues[orderId] = previousNotes ?? ''
if (!isSessionExpired(err)) { notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
}
} finally { } finally {
savingNotesId.value = null savingNotesId.value = null
} }

View file

@ -1,6 +1,5 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { fetchAllOrders, type AdminOrder } from '@/api/admin' import { fetchAllOrders, type AdminOrder } from '@/api/admin'
import { isSessionExpired } from '@/api/client'
import { PAID_GROUP_STATUSES } from '@/constants/orderStatus' import { PAID_GROUP_STATUSES } from '@/constants/orderStatus'
export type AdminOrderFilter = export type AdminOrderFilter =
@ -62,10 +61,8 @@ export function useAdminOrders() {
error.value = '' error.value = ''
try { try {
orders.value = await fetchAllOrders() orders.value = await fetchAllOrders()
} catch (err) { } catch {
if (!isSessionExpired(err)) { error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }

View file

@ -1,95 +0,0 @@
/**
* useSeo — dynamically updates document <title> and meta tags on route change.
*
* Usage:
* const seo = useSeo()
* // On page mount / watch:
* watch(route, () => {
* seo.set({ title: 'Min sida', description: '...' })
* })
*
* The composable sets <title>, meta[name=description], meta[name=keywords],
* meta[property=og:title], meta[property=og:description], meta[name=twitter:title],
* and meta[name=twitter:description].
*/
export interface SeoMeta {
title: string
description: string
keywords?: string
ogTitle?: string
ogDescription?: string
canonical?: string
}
export function useSeo() {
function set(meta: SeoMeta) {
const title = meta.title ?? 'Bilhej'
const description =
meta.description ??
'Skicka brev till fordonsägare via registreringsnummer.'
// Document title
document.title = title
// Common meta tags
updateMeta('description', description)
if (meta.keywords) updateMeta('keywords', meta.keywords)
// Open Graph
updateMeta('og:title', meta.ogTitle ?? title, 'property')
updateMeta('og:description', meta.ogDescription ?? description, 'property')
// Twitter
updateMeta('twitter:title', meta.ogTitle ?? title, 'name')
updateMeta('twitter:description', meta.ogDescription ?? description, 'name')
// Canonical URL
updateCanonical(meta.canonical)
}
function reset() {
document.title = 'Bilhej — Skicka brev till fordonsägare'
updateMeta(
'description',
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
)
updateMeta('og:title', 'Bilhej — Skicka brev till fordonsägare', 'property')
updateMeta(
'og:description',
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
'property',
)
updateCanonical('https://bilhej.se/')
}
return { set, reset }
}
function updateMeta(
nameOrProperty: string,
content: string,
attr: 'name' | 'property' = 'name',
) {
let el = document.querySelector(
`meta[${attr}="${nameOrProperty}"]`,
) as HTMLMetaElement | null
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, nameOrProperty)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
function updateCanonical(href: string | undefined) {
let link = document.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.setAttribute('rel', 'canonical')
document.head.appendChild(link)
}
link.setAttribute('href', href ?? 'https://bilhej.se/')
}

View file

@ -1,90 +0,0 @@
/**
* Route-level SEO metadata for Bilhej.
* Each entry maps route.name → { title, description, keywords?, canonical? }
* These are applied by App.vue on every route change via useSeo().
*/
export interface RouteSeoMeta {
title: string
description: string
keywords?: string
/** The full canonical URL for this page */
canonical?: string
}
export const routeSeo: Record<string, RouteSeoMeta> = {
home: {
title: 'Bilhej — Skicka brev till fordonsägare via registreringsnummer',
description:
'Skicka ett fysiskt brev till en bilägare med bara registreringsnumret. Välj mall, skriv, betala 49 kr via Swish. Vi postar med spårning.',
keywords:
'brev till bilägare, kontakta fordonsägare, parkeringsskada, köp bil, skicka brev, registreringsnummer brev',
},
about: {
title: 'Om Bilhej — Skicka fysiska brev till fordonsägare online',
description:
'Bilhej gör det enkelt att nå en bilägare med ett fysiskt brev. Skriv meddelandet, vi sköter utskick och post. Anonymt eller med dina uppgifter.',
keywords: 'om bilhej, hur fungerar bilhej, skicka brev till bilägare',
},
contact: {
title: 'Kontakt — Bilhej support, klagomål och allmänna frågor',
description:
'Kontakta Bilhej via e-post. Support för beställningar och betalning, allmän kontakt och klagomål.',
keywords: 'bilhej kontakt, support bilhej, klagomål',
},
privacy: {
title: 'Integritetspolicy — Bilhej | Personuppgifter och GDPR',
description:
'Läs om hur Bilhej hanterar personuppgifter: vad vi samlar in, varför, hur länge och dina rättigheter enligt GDPR.',
keywords: 'integritetspolicy bilhej, gdpr, personuppgifter brev',
},
terms: {
title: 'Användarvillkor — Bilhej | Villkor för brevtjänsten',
description:
'Villkor för att använda Bilhej. Regler för brev till fordonsägare, betalning, ansvar och reklamation.',
keywords: 'användarvillkor bilhej, köpvillkor, brevtjänst villkor',
},
register: {
title: 'Registrera konto — Bilhej | Skapa konto för beställningar',
description:
'Skapa ett konto på Bilhej för att följa dina beställningar, se historik och hantera dina brev.',
},
login: {
title: 'Logga in — Bilhej | Dina brev till fordonsägare',
description:
'Logga in på Bilhej för att se dina beställningar, status och spårning.',
},
'forgot-password': {
title: 'Glömt lösenord — Bilhej | Återställ lösenord',
description:
'Återställ ditt lösenord på Bilhej. Få en återställningslänk via e-post.',
},
'guest-checkout': {
title: 'Gästbeställning — Bilhej | Skicka brev utan konto',
description:
'Skicka ett brev till en fordonsägare utan att skapa konto. Ange registreringsnummer, skriv meddelande, betala via Swish.',
keywords: 'gästbeställning bilhej, utan konto, snabb beställning',
},
compose: {
title: 'Skriv brev — Bilhej | Komponera meddelande till fordonsägare',
description:
'Skriv ditt brev till bilägaren. Välj mall för köp, komplimang, parkeringsskada eller tips, eller skriv fritt.',
},
orders: {
title: 'Mina beställningar — Bilhej | Följ dina brev',
description:
'Se dina tidigare och pågående beställningar. Följ status och spårning för brev du skickat.',
},
'change-password': {
title: 'Byt lösenord — Bilhej | Kontoinställningar',
description: 'Byt lösenord på ditt Bilhej-konto.',
},
'change-email': {
title: 'Byt e-postadress — Bilhej | Kontoinställningar',
description: 'Byt e-postadress på ditt Bilhej-konto.',
},
admin: {
title: 'Admin — Bilhej | Administrationspanel',
description:
'Administration av Bilhej — hantera beställningar och användare.',
},
}

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

@ -1,22 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly VITE_UMAMI_WEBSITE_ID?: string
readonly VITE_UMAMI_SCRIPT_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
interface Window {
umami?: {
track: (
input?:
| string
| Record<string, unknown>
| ((props: Record<string, unknown>) => Record<string, unknown>),
) => void
}
}

View file

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

View file

@ -37,10 +37,6 @@ const {
handleNotesSave, handleNotesSave,
} = useAdminOrderActions(orders, replaceOrder) } = useAdminOrderActions(orders, replaceOrder)
const umamiDashboardUrl = import.meta.env.VITE_UMAMI_WEBSITE_ID
? 'https://analytics.bilhej.se'
: null
function handleModalKeydown(event: KeyboardEvent) { function handleModalKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && messageModalOrder.value) { if (event.key === 'Escape' && messageModalOrder.value) {
closeMessageModal() closeMessageModal()
@ -59,18 +55,7 @@ onUnmounted(() => {
<template> <template>
<div class="admin"> <div class="admin">
<header class="admin__header"> <h1 class="admin__title">Administration</h1>
<h1 class="admin__title">Administration</h1>
<a
v-if="umamiDashboardUrl"
class="admin__analytics-link"
:href="umamiDashboardUrl"
target="_blank"
rel="noopener noreferrer"
>
Webbstatistik
</a>
</header>
<p <p
v-if="loading" v-if="loading"
@ -145,40 +130,19 @@ onUnmounted(() => {
</div> </div>
</template> </template>
<style> <style scoped>
.admin { .admin {
max-width: 72rem; max-width: 72rem;
margin: var(--space-2xl) auto 0; margin: var(--space-2xl) auto 0;
padding: 0 var(--space-lg); padding: 0 var(--space-lg);
} }
.admin__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.admin__title { .admin__title {
margin: 0; margin: 0 0 var(--space-xl) 0;
font-size: 1.5rem; font-size: 1.5rem;
color: var(--color-ink); color: var(--color-ink);
} }
.admin__analytics-link {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.admin__analytics-link:hover {
color: var(--color-primary-dark);
}
.admin__stats { .admin__stats {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);

View file

@ -2,7 +2,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders' import { createOrder } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
@ -42,10 +41,8 @@ async function handleSubmit() {
params: { orderId: order.id }, params: { orderId: order.id },
query: { plate: plate.value }, query: { plate: plate.value },
}) })
} catch (err) { } catch {
if (!isSessionExpired(err)) { errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }

View file

@ -2,7 +2,6 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { fetchOrder, updateOrder, type Order } from '@/api/orders' import { fetchOrder, updateOrder, type Order } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
@ -45,10 +44,8 @@ async function loadOrder() {
if (fetched.status === 'pending_payment') { if (fetched.status === 'pending_payment') {
letterText.value = fetched.letterText letterText.value = fetched.letterText
} }
} catch (err) { } catch {
if (!isSessionExpired(err)) { loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -67,10 +64,8 @@ async function handleSubmit() {
params: { orderId: order.value.id }, params: { orderId: order.value.id },
query: { plate: order.value.plate }, query: { plate: order.value.plate },
}) })
} catch (err) { } catch {
if (!isSessionExpired(err)) { errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }

View file

@ -1,189 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { createGuestOrder } from '@/api/guestOrders'
const router = useRouter()
const route = useRoute()
const plate = ref((route.query.plate as string) || '')
const letterText = ref('')
const email = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const maxChars = 1000
const charCount = computed(() => letterText.value.length)
const PLATE_RE = /^[A-Za-z]{3}\d{2}[A-Za-z0-9]$/
const canSubmit = computed(
() =>
PLATE_RE.test(plate.value.trim()) &&
letterText.value.trim().length > 0 &&
/\S+@\S+\.\S+/.test(email.value.trim()) &&
!submitting.value,
)
async function handleSubmit() {
if (!canSubmit.value) return
submitting.value = true
errorMessage.value = ''
try {
const order = await createGuestOrder(
plate.value.trim(),
letterText.value,
email.value.trim(),
)
// Token rides in the query string so the payment page survives refresh.
await router.push({
name: 'guest-payment',
params: { orderId: order.id },
query: { token: order.guestToken, plate: order.plate },
})
} catch {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="guest-checkout">
<div class="guest-checkout__card">
<h1 class="guest-checkout__title">Skicka ett brev</h1>
<p class="guest-checkout__subtitle">
49 kr — betala med Swish. Inget konto behövs.
</p>
<form class="guest-checkout__form" @submit.prevent="handleSubmit">
<div class="field">
<label for="plate" class="field__label">Registreringsnummer</label>
<input
id="plate"
v-model="plate"
type="text"
class="field__input"
placeholder="ABC123"
maxlength="6"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="field">
<label for="letter" class="field__label">Ditt meddelande</label>
<textarea
id="letter"
v-model="letterText"
class="field__input guest-checkout__textarea"
:maxlength="maxChars"
rows="10"
placeholder="Skriv ditt meddelande här..."
></textarea>
<p class="field__hint guest-checkout__counter">
{{ charCount }} / {{ maxChars }} tecken
</p>
</div>
<div class="field">
<label for="email" class="field__label"
>E-post (för kvitto och orderlänk)</label
>
<input
id="email"
v-model="email"
type="email"
class="field__input"
placeholder="namn@example.se"
autocomplete="email"
/>
<p class="field__hint">
Vi skickar en magisk länk så du kan följa ditt brev senare.
</p>
</div>
<div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn--primary btn--lg guest-checkout__submit"
:disabled="!canSubmit"
>
{{ submitting ? 'Skickar...' : 'Fortsätt till betalning' }}
</button>
<p class="guest-checkout__login-hint">
Har du redan ett konto?
<RouterLink to="/logga-in">Logga in</RouterLink>
</p>
</form>
</div>
</div>
</template>
<style scoped>
.guest-checkout {
max-width: 32rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto;
padding: 0 var(--page-gutter);
}
.guest-checkout__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.guest-checkout__title {
margin: 0 0 var(--space-xs) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.guest-checkout__subtitle {
margin: 0 0 var(--space-xl) 0;
color: var(--color-muted);
font-size: 0.9375rem;
}
.guest-checkout__form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.guest-checkout__textarea {
resize: vertical;
min-height: 10rem;
font-family: inherit;
}
.guest-checkout__counter {
text-align: right;
}
.guest-checkout__submit {
width: 100%;
margin-top: var(--space-sm);
}
.guest-checkout__login-hint {
text-align: center;
margin: var(--space-sm) 0 0 0;
font-size: 0.875rem;
color: var(--color-muted);
}
.guest-checkout__login-hint a {
color: var(--color-primary);
text-decoration: underline;
}
</style>

View file

@ -1,198 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { fetchGuestOrder, type GuestOrder } from '@/api/guestOrders'
const route = useRoute()
const token = route.params.token as string
const order = ref<GuestOrder | null>(null)
const loading = ref(true)
const error = ref('')
const statusLabel = computed(() => {
if (!order.value) return ''
switch (order.value.status) {
case 'pending_payment':
return 'Väntar på betalning'
case 'paid':
case 'processing':
return 'Behandlas'
case 'sent':
return 'Skickat'
case 'delivered':
return 'Levererat'
case 'failed':
return 'Misslyckades'
case 'cancelled':
return 'Avbrutet'
default:
return order.value.status
}
})
onMounted(async () => {
if (!token) {
error.value = 'Ogiltig orderlänk.'
loading.value = false
return
}
try {
order.value = await fetchGuestOrder(token)
} catch {
error.value = 'Kunde inte hitta beställningen. Kontrollera länken.'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="guest-order">
<div class="guest-order__card">
<h1 class="guest-order__title">Din beställning</h1>
<div v-if="loading" class="guest-order__state">Laddar…</div>
<div v-else-if="error" class="message message--error">{{ error }}</div>
<template v-else-if="order">
<div class="guest-order__row">
<span class="guest-order__label">Registreringsnummer</span>
<span class="guest-order__value">{{ order.plate }}</span>
</div>
<div class="guest-order__row">
<span class="guest-order__label">Beställnings-ID</span>
<span class="guest-order__value guest-order__value--mono">{{
order.id
}}</span>
</div>
<div class="guest-order__row">
<span class="guest-order__label">Skapad</span>
<span class="guest-order__value">
{{ new Date(order.createdAt).toLocaleString('sv-SE') }}
</span>
</div>
<hr class="guest-order__divider" />
<div class="guest-order__row guest-order__row--status">
<span class="guest-order__label">Status</span>
<span class="guest-order__status">{{ statusLabel }}</span>
</div>
<p v-if="order.status === 'pending_payment'" class="guest-order__hint">
<RouterLink
:to="{
name: 'guest-payment',
params: { orderId: order.id },
query: { token, plate: order.plate },
}"
>
Gå till betalningssidan
</RouterLink>
</p>
<div class="guest-order__letter">
<p class="guest-order__letter-label">Ditt brev</p>
<p class="guest-order__letter-body">{{ order.letterText }}</p>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.guest-order {
max-width: 32rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto;
padding: 0 var(--page-gutter);
}
.guest-order__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.guest-order__title {
margin: 0 0 var(--space-lg) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.guest-order__row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-md);
margin-bottom: var(--space-sm);
}
.guest-order__row--status {
margin-top: var(--space-sm);
}
.guest-order__label {
font-size: 0.8125rem;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.guest-order__value {
font-size: 0.9375rem;
color: var(--color-ink);
text-align: right;
}
.guest-order__value--mono {
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
word-break: break-all;
}
.guest-order__divider {
margin: var(--space-md) 0;
border: none;
border-top: 1px solid var(--color-border);
}
.guest-order__status {
font-size: 1rem;
font-weight: 600;
color: var(--color-primary-dark);
}
.guest-order__hint {
margin: var(--space-lg) 0 0 0;
font-size: 0.875rem;
}
.guest-order__hint a {
color: var(--color-primary);
text-decoration: underline;
}
.guest-order__letter {
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border);
}
.guest-order__letter-label {
margin: 0 0 var(--space-sm) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.guest-order__letter-body {
margin: 0;
font-family: var(--font-serif);
font-size: 0.9375rem;
line-height: 1.7;
color: var(--color-ink);
white-space: pre-wrap;
}
</style>

View file

@ -1,386 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import QRCode from 'qrcode'
import { fetchGuestOrder, payGuestOrder } from '@/api/guestOrders'
import { fetchSwishInfo, buildSwishPaymentUrl } from '@/api/payment'
const router = useRouter()
const route = useRoute()
const orderId = route.params.orderId as string
const token = (route.query.token as string) || ''
const plate = ref((route.query.plate as string) || '')
const swishNumber = ref('')
const swishAmount = ref(49)
const paying = ref(false)
const error = ref('')
const showConfirmation = ref(false)
const qrDataUrl = ref('')
const swishPaymentUrl = computed(() =>
swishNumber.value
? buildSwishPaymentUrl(swishNumber.value, swishAmount.value, orderId)
: '',
)
const magicOrderUrl = computed(() =>
token ? `${window.location.origin}/gast-order/${token}` : '',
)
onMounted(async () => {
if (!token) {
error.value = 'Saknar order-token. Gå tillbaka och försök igen.'
return
}
try {
const info = await fetchSwishInfo()
swishNumber.value = info.number
swishAmount.value = info.amount
// Pre-load plate display so the payment page shows what they're paying for
// even if they opened it directly without the plate query string.
const order = await fetchGuestOrder(token)
if (order.status !== 'pending_payment') {
// Already paid — bounce them to the status page.
await router.push({ name: 'guest-order', params: { token } })
return
}
plate.value = plate.value || order.plate
if (swishPaymentUrl.value) {
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
width: 224,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
})
}
} catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
}
})
function startPayment() {
showConfirmation.value = true
}
function cancelPayment() {
showConfirmation.value = false
}
async function confirmPayment() {
paying.value = true
error.value = ''
try {
await payGuestOrder(token)
await router.push({ name: 'guest-order', params: { token } })
} catch {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
} finally {
paying.value = false
}
}
</script>
<template>
<div class="page">
<div class="page__card">
<h1 class="page__title">Betalning</h1>
<p class="page__plate">
Registreringsnummer: <strong>{{ plate || '—' }}</strong>
</p>
<div class="payment__order-ref">
<p class="payment__order-ref-label">Beställnings-ID</p>
<p class="payment__order-id">{{ orderId }}</p>
</div>
<div class="payment__summary">
<div class="payment__row">
<span class="payment__label">Att betala</span>
<span class="payment__amount">{{ swishAmount }} kr</span>
</div>
</div>
<div
v-if="error"
class="message message--error"
style="margin-bottom: var(--space-md)"
>
{{ error }}
</div>
<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">
<p class="payment__swish-label">Swisha till</p>
<p class="payment__swish-number">{{ swishNumber }}</p>
<p class="payment__swish-instruction">
Belopp och beställnings-ID fylls i automatiskt via QR-kod eller
länk.
</p>
<p class="payment__swish-instruction">
Betala manuellt om du inte har Swish-appen tillgänglig.
</p>
</div>
<button class="btn btn--ghost payment__submit" @click="startPayment">
Jag har betalat
</button>
</template>
<template v-else>
<div class="payment__confirm">
<p class="payment__confirm-text">
Jag bekräftar att jag har Swishat {{ swishAmount }} kr till
{{ swishNumber }} med meddelande: {{ orderId }}.
</p>
<div class="payment__confirm-actions">
<button
class="btn btn--ghost payment__confirm-cancel"
:disabled="paying"
@click="cancelPayment"
>
Avbryt
</button>
<button
class="btn btn--primary"
:disabled="paying"
@click="confirmPayment"
>
{{ paying ? 'Bearbetar...' : 'Ja, jag har betalat' }}
</button>
</div>
</div>
</template>
<!-- Magic order link — shown after page mount so the user can copy it now -->
<div v-if="magicOrderUrl" class="guest-payment__magic-link">
<p class="payment__swish-label">Din orderlänk</p>
<p class="guest-payment__magic-hint">
Spara denna länk för att följa ditt brev senare. (E-postbekräftelse
kommer i en senare fas.)
</p>
<code class="guest-payment__magic-url">{{ magicOrderUrl }}</code>
</div>
</div>
</div>
</template>
<style scoped>
.page {
max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.page__plate {
margin: 0 0 var(--space-md) 0;
font-size: 0.875rem;
color: var(--color-muted);
}
.payment__order-ref {
margin: 0 0 var(--space-xl) 0;
padding: var(--space-md);
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.payment__order-ref-label {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.payment__order-id {
margin: 0;
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
color: var(--color-ink);
word-break: break-all;
line-height: 1.5;
}
.payment__summary {
margin-bottom: var(--space-lg);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
.payment__row {
display: flex;
justify-content: space-between;
align-items: center;
}
.payment__label {
font-size: 0.875rem;
color: var(--color-muted);
}
.payment__amount {
font-size: 1.5rem;
font-weight: 700;
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 {
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
text-align: center;
}
.payment__swish-label {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.payment__swish-number {
margin: 0 0 var(--space-md) 0;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-ink);
letter-spacing: 0.05em;
}
.payment__swish-instruction {
margin: 0;
font-size: 0.8125rem;
color: var(--color-muted);
line-height: 1.5;
}
.payment__swish-instruction + .payment__swish-instruction {
margin-top: var(--space-xs);
}
.payment__submit {
width: 100%;
}
.payment__confirm {
padding: var(--space-md) 0;
}
.payment__confirm-text {
margin: 0 0 var(--space-lg) 0;
font-size: 0.9375rem;
color: var(--color-ink);
line-height: 1.6;
}
.payment__confirm-actions {
display: flex;
gap: var(--space-md);
}
.payment__confirm-cancel {
flex: 1;
}
.payment__confirm-actions .btn--primary {
flex: 2;
}
.guest-payment__magic-link {
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border);
}
.guest-payment__magic-hint {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
color: var(--color-muted);
line-height: 1.5;
}
.guest-payment__magic-url {
display: block;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: var(--color-ink);
background: var(--color-border-light);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
word-break: break-all;
user-select: all;
}
@media (max-width: 639px) {
.page {
padding: 0 var(--page-gutter);
}
}
</style>

View file

@ -1,23 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import PlateInput from '@/components/PlateInput.vue' import PlateInput from '@/components/PlateInput.vue'
import VehicleInfo from '@/components/VehicleInfo.vue' import VehicleInfo from '@/components/VehicleInfo.vue'
import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue' import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue'
import { lookupVehicle } from '@/api/vehicles' import { lookupVehicle } from '@/api/vehicles'
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
// When unauthenticated, the homepage CTA sends the user to the guest
// checkout page instead of the auth-protected /compose route.
// Without this, anonymous users hit the router guard and are bounced
// to /logga-in — the guest flow we built was unreachable from the UI.
const ctaTarget = computed(() =>
auth.isAuthenticated
? { name: 'compose' as const, query: { plate: plate.value } }
: { name: 'guest-checkout' as const, query: { plate: plate.value } },
)
const useCases = [ const useCases = [
{ {
@ -217,7 +204,7 @@ async function handleLookup(lookedUpPlate: string) {
<RouterLink <RouterLink
v-if="vehicle" v-if="vehicle"
:to="ctaTarget" :to="{ name: 'compose', query: { plate } }"
class="btn btn--primary btn--lg home__cta" class="btn btn--primary btn--lg home__cta"
> >
Fortsätt till brevet Fortsätt till brevet

View file

@ -2,7 +2,6 @@
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 { import {
ORDER_STATUS_BADGE, ORDER_STATUS_BADGE,
@ -66,10 +65,8 @@ async function loadOrders() {
]) ])
orders.value = fetchedOrders orders.value = fetchedOrders
orderAmount.value = swishInfo.amount orderAmount.value = swishInfo.amount
} catch (err) { } catch {
if (!isSessionExpired(err)) { error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -90,11 +87,8 @@ async function handleCancel(order: Order) {
try { try {
const updated = await cancelOrder(order.id) const updated = await cancelOrder(order.id)
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o)) orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
} catch (err) { } catch {
if (!isSessionExpired(err)) { actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
actionError.value =
'Kunde inte avbryta beställningen. Försök igen senare.'
}
} finally { } finally {
cancellingId.value = null cancellingId.value = null
} }

View file

@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import QRCode from 'qrcode' import { payOrder, fetchSwishInfo } from '@/api/payment'
import { payOrder, fetchSwishInfo, buildSwishPaymentUrl } from '@/api/payment'
import { isSessionExpired } from '@/api/client'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -14,13 +12,6 @@ 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 {
@ -29,28 +20,6 @@ onMounted(async () => {
swishAmount.value = info.amount swishAmount.value = info.amount
} catch { } catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.' error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
return
}
// QR generation is best-effort and isolated from fetchSwishInfo: if the QR
// library throws, the Swish payment link and manual fallback still render
// instead of surfacing a misleading "could not load payment info" error.
try {
if (swishPaymentUrl.value) {
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
// Swish requires a reliably scannable black-on-white QR. The previous
// settings (margin 2, #111827, 224px) produced a 2-module quiet zone
// — half the QR spec minimum — which the Swish app's scanner fails to
// read when scanning off a screen. Use the spec-compliant 4-module
// quiet zone, pure black, and larger modules.
width: 288,
margin: 4,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
})
}
} catch {
// ignored: payment link + manual fallback remain usable
} }
}) })
@ -69,10 +38,8 @@ async function confirmPayment() {
try { try {
await payOrder(orderId) await payOrder(orderId)
await router.push({ name: 'orders' }) await router.push({ name: 'orders' })
} catch (err) { } catch {
if (!isSessionExpired(err)) { error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
}
} finally { } finally {
paying.value = false paying.value = false
} }
@ -108,37 +75,21 @@ async function confirmPayment() {
</div> </div>
<template v-if="!showConfirmation"> <template v-if="!showConfirmation">
<!-- QR code — scan with the Swish app (desktop users) -->
<div v-if="qrDataUrl" class="payment__qr">
<img :src="qrDataUrl" alt="Swish QR-kod" class="payment__qr-img" />
<p class="payment__qr-hint">
Skanna QR-koden med Swish-appen för att betala
</p>
</div>
<!-- Direct link — opens the Swish app (mobile users) -->
<a
v-if="swishPaymentUrl"
:href="swishPaymentUrl"
class="btn btn--primary btn--lg payment__swish-link"
>
Betala med Swish
</a>
<!-- Manual fallback -->
<div class="payment__swish"> <div class="payment__swish">
<p class="payment__swish-label">Swisha till</p> <p class="payment__swish-label">Swisha till</p>
<p class="payment__swish-number">{{ swishNumber }}</p> <p class="payment__swish-number">{{ swishNumber }}</p>
<p class="payment__swish-instruction"> <p class="payment__swish-instruction">
Belopp och beställnings-ID fylls i automatiskt via QR-kod eller Ange beställnings-ID ovan som meddelande i Swish-appen.
länk.
</p> </p>
<p class="payment__swish-instruction"> <p class="payment__swish-instruction">
Betala manuellt om du inte har Swish-appen tillgänglig. Tryck sedan på knappen nedan för att bekräfta.
</p> </p>
</div> </div>
<button class="btn btn--ghost payment__submit" @click="startPayment"> <button
class="btn btn--primary btn--lg payment__submit"
@click="startPayment"
>
Jag har betalat Jag har betalat
</button> </button>
</template> </template>
@ -247,31 +198,6 @@ async function confirmPayment() {
color: var(--color-ink); color: var(--color-ink);
} }
.payment__qr {
text-align: center;
margin-bottom: var(--space-lg);
}
.payment__qr-img {
width: 288px;
height: 288px;
border-radius: var(--radius-md);
margin: 0 auto var(--space-sm);
}
.payment__qr-hint {
font-size: 0.8125rem;
color: var(--color-muted);
}
.payment__swish-link {
display: block;
width: 100%;
text-align: center;
text-decoration: none;
margin-bottom: var(--space-lg);
}
.payment__swish { .payment__swish {
background: var(--color-border-light); background: var(--color-border-light);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);

View file

@ -40,15 +40,6 @@ const sections = [
'Vi säljer inte personuppgifter och visar inte mottagarens identitet eller adress för dig som avsändare.', 'Vi säljer inte personuppgifter och visar inte mottagarens identitet eller adress för dig som avsändare.',
], ],
}, },
{
id: 'webbanalys',
title: 'Webbstatistik',
paragraphs: [
'Vi använder självhostad webbanalys (Umami) på analytics.bilhej.se för att förstå hur webbplatsen används, till exempel vilka sidor som besöks och ungefärlig geografisk fördelning på landsnivå.',
'Analysen bygger på sidvisningar och teknisk information som webbläsaren skickar vid besök. Vi lagrar inte besökares IP-adresser i Bilhejs databas; Umami behandlar IP tillfälligt för att kunna visa land och tar inte emot personuppgifter som du skriver i brev eller konto.',
'Du kan begränsa spårning med webbläsarens spärrlistor eller “Do Not Track”. Kontakta oss om du har frågor om webbanalys.',
],
},
{ {
id: 'lagring', id: 'lagring',
title: 'Hur länge sparar vi uppgifterna?', title: 'Hur länge sparar vi uppgifterna?',

View file

@ -20,9 +20,6 @@ import OrdersPage from '@/pages/OrdersPage.vue'
import EditOrderPage from '@/pages/EditOrderPage.vue' import EditOrderPage from '@/pages/EditOrderPage.vue'
import AdminPage from '@/pages/AdminPage.vue' import AdminPage from '@/pages/AdminPage.vue'
import PaymentRedirect from '@/pages/PaymentRedirect.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue'
import GuestOrderPage from '@/pages/GuestOrderPage.vue'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { getActivePinia } from 'pinia' import { getActivePinia } from 'pinia'
@ -91,24 +88,6 @@ const router = createRouter({
component: PaymentRedirect, component: PaymentRedirect,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
// Guest checkout — no account required to place and pay for an order.
path: '/gast-bestallning',
name: 'guest-checkout',
component: GuestCheckoutPage,
},
{
// Guest payment page — token carried in query so refresh keeps the session.
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: GuestPaymentRedirect,
},
{
// Magic-link landing — order status by opaque token.
path: '/gast-order/:token',
name: 'guest-order',
component: GuestOrderPage,
},
{ {
path: '/registrera', path: '/registrera',
name: 'register', name: 'register',
@ -169,11 +148,9 @@ router.beforeEach((to) => {
if (!getActivePinia()) return if (!getActivePinia()) return
const auth = useAuthStore() const auth = useAuthStore()
const authenticated = auth.isAuthenticated && !auth.isTokenExpired()
if (to.meta.guestOnly && authenticated) return { name: 'home' } if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' }
if (to.meta.requiresAuth && !authenticated) { if (to.meta.requiresAuth && !auth.isAuthenticated) {
if (auth.isAuthenticated) auth.logout()
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' } if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }

View file

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

View file

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

View file

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

View file

@ -12,12 +12,8 @@ export default defineConfig({
}, },
server: { server: {
port: 3000, port: 3000,
host: true,
proxy: { proxy: {
// Allow running Vite locally outside Docker (set '/api': 'http://backend:8080',
// VITE_API_PROXY_TARGET=http://localhost:8080 npm run dev) by pointing
// the proxy at the host port instead of the compose service name.
'/api': process.env.VITE_API_PROXY_TARGET || 'http://backend:8080',
}, },
}, },
preview: { preview: {