Compare commits
No commits in common. "master" and "fix/admin-table-styling" have entirely different histories.
master
...
fix/admin-
40 changed files with 85 additions and 3270 deletions
|
|
@ -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) ----------
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -158,7 +134,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
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ 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())
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
package se.bilhalsning.controller;
|
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import se.bilhalsning.dto.CreateGuestOrderRequest;
|
|
||||||
import se.bilhalsning.dto.GuestOrderResponse;
|
|
||||||
import se.bilhalsning.entity.Order;
|
|
||||||
import se.bilhalsning.service.OrderService;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public (no-JWT) endpoints for placing and paying for orders without an
|
|
||||||
* account — guest checkout.
|
|
||||||
*
|
|
||||||
* Auth: white-listed in {@code SecurityConfig} so no JWT filter runs on
|
|
||||||
* these paths. The guest token (UUID v4, generated at order create time
|
|
||||||
* in {@link Order#onCreate()}) is the only credential the client holds
|
|
||||||
* and must pass as a path variable. Token brute-force resistance: 122
|
|
||||||
* bits of entropy.
|
|
||||||
*
|
|
||||||
* Routes parallel {@link OrderController} deliberately so the JWT path
|
|
||||||
* stays clean and unmodified.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/guest-orders")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class GuestOrderController {
|
|
||||||
|
|
||||||
private final OrderService orderService;
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
public ResponseEntity<GuestOrderResponse> create(
|
|
||||||
@Valid @RequestBody CreateGuestOrderRequest request) {
|
|
||||||
Order order = orderService.createGuestOrder(
|
|
||||||
request.plate(),
|
|
||||||
request.letterText(),
|
|
||||||
request.email()
|
|
||||||
);
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{token}")
|
|
||||||
public ResponseEntity<GuestOrderResponse> get(@PathVariable UUID token) {
|
|
||||||
Order order = orderService.getOrderByGuestToken(token);
|
|
||||||
return ResponseEntity.ok(toResponse(order));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{token}/pay")
|
|
||||||
public ResponseEntity<GuestOrderResponse> pay(@PathVariable UUID token) {
|
|
||||||
Order order = orderService.confirmGuestPayment(token);
|
|
||||||
return ResponseEntity.ok(toResponse(order));
|
|
||||||
}
|
|
||||||
|
|
||||||
private GuestOrderResponse toResponse(Order order) {
|
|
||||||
return new GuestOrderResponse(
|
|
||||||
order.getId(),
|
|
||||||
order.getPlate(),
|
|
||||||
order.getLetterText(),
|
|
||||||
order.getStatus().getValue(),
|
|
||||||
order.getTrackingId(),
|
|
||||||
order.getAmountPaid(),
|
|
||||||
order.getCreatedAt(),
|
|
||||||
order.getGuestToken()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.Pattern;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an order without an account (guest checkout).
|
|
||||||
*
|
|
||||||
* The {@code plate} validation mirrors {@link CreateOrderRequest} so the
|
|
||||||
* guest path accepts the same Swedish 3-letter + 3-char plate format.
|
|
||||||
*/
|
|
||||||
public record CreateGuestOrderRequest(
|
|
||||||
@NotBlank(message = "Registreringsnummer krävs")
|
|
||||||
@Pattern(regexp = "^[A-Za-z]{3}\\d{2}[A-Za-z0-9]$", message = "Ogiltigt registreringsnummer")
|
|
||||||
String plate,
|
|
||||||
|
|
||||||
@NotBlank(message = "Brevtext krävs")
|
|
||||||
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
|
|
||||||
String letterText,
|
|
||||||
|
|
||||||
@NotBlank(message = "E-post krävs")
|
|
||||||
@Email(message = "Ogiltig e-postadress")
|
|
||||||
@Size(max = 255, message = "E-postadressen är för lång")
|
|
||||||
String email
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response shape for guest-order endpoints.
|
|
||||||
*
|
|
||||||
* Identical to {@link OrderResponse} plus {@link #guestToken}, which the
|
|
||||||
* client needs to (a) build the payment page URL and (b) look up the
|
|
||||||
* order again without an account. Token is returned only on create and
|
|
||||||
* by-token lookup — it is never re-exposed by order ID alone.
|
|
||||||
*/
|
|
||||||
public record GuestOrderResponse(
|
|
||||||
UUID id,
|
|
||||||
String plate,
|
|
||||||
String letterText,
|
|
||||||
String status,
|
|
||||||
String trackingId,
|
|
||||||
BigDecimal amountPaid,
|
|
||||||
Instant createdAt,
|
|
||||||
UUID guestToken
|
|
||||||
) {}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
-- Allows orders without a registered user (guest checkout).
|
|
||||||
-- Users can place and pay for letters without creating an account.
|
|
||||||
--
|
|
||||||
-- user_id: previously NOT NULL - drop the constraint so guest orders
|
|
||||||
-- can be created without a registered user. The FK stays in
|
|
||||||
-- place (NULL user_id is FK-legal).
|
|
||||||
-- guest_email: contact address for the guest. Used to send the magic
|
|
||||||
-- link that lets them revisit their order status.
|
|
||||||
-- guest_token: opaque UUID v4 - the only credential a guest has. Acts
|
|
||||||
-- as their session token for order lookup + payment confirm.
|
|
||||||
|
|
||||||
ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL;
|
|
||||||
ALTER TABLE orders ADD COLUMN guest_email VARCHAR(255);
|
|
||||||
ALTER TABLE orders ADD COLUMN guest_token UUID;
|
|
||||||
|
|
||||||
-- Unique index on guest_token. Both H2 (tests/dev) and PostgreSQL (prod)
|
|
||||||
-- treat NULLs as distinct in a UNIQUE index, so user-owned orders (which
|
|
||||||
-- have a NULL token) never collide, while non-NULL guest tokens are
|
|
||||||
-- enforced unique. A plain index is used instead of a partial
|
|
||||||
-- (WHERE guest_token IS NOT NULL) index because H2 does not support
|
|
||||||
-- partial indexes, and the plain form preserves the intended semantics.
|
|
||||||
CREATE UNIQUE INDEX idx_orders_guest_token
|
|
||||||
ON orders(guest_token);
|
|
||||||
CREATE INDEX idx_orders_guest_email
|
|
||||||
ON orders(guest_email);
|
|
||||||
|
|
@ -1,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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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...';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
356
frontend/package-lock.json
generated
356
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
# Allow all crawlers
|
|
||||||
User-agent: *
|
|
||||||
Crawl-delay: 10
|
|
||||||
Disallow: /api/
|
|
||||||
Disallow: /admin/
|
|
||||||
Disallow: /orders/
|
|
||||||
Disallow: /bestallning/*/redigera
|
|
||||||
Disallow: /betalning/*
|
|
||||||
Disallow: /gast-bestallning*
|
|
||||||
Disallow: /gast-betalning/*
|
|
||||||
Disallow: /gast-order/*
|
|
||||||
Disallow: /andra-losenord
|
|
||||||
Disallow: /andra-epost
|
|
||||||
Disallow: /bekrafta-epost
|
|
||||||
Disallow: /aterstall-losenord
|
|
||||||
Disallow: /glomt-losenord
|
|
||||||
|
|
||||||
# Allow important pages
|
|
||||||
Allow: /
|
|
||||||
Allow: /om-oss
|
|
||||||
Allow: /om
|
|
||||||
Allow: /kontakt
|
|
||||||
Allow: /integritetspolicy
|
|
||||||
Allow: /villkor
|
|
||||||
|
|
||||||
# Sitemap
|
|
||||||
Sitemap: https://bilhej.se/sitemap.xml
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
|
||||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
|
||||||
<url>
|
|
||||||
<loc>https://bilhej.se/</loc>
|
|
||||||
<changefreq>weekly</changefreq>
|
|
||||||
<priority>1.0</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://bilhej.se/om-oss</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://bilhej.se/om</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.3</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://bilhej.se/kontakt</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.6</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://bilhej.se/integritetspolicy</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.5</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://bilhej.se/villkor</loc>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.5</priority>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
|
|
@ -1,29 +1,7 @@
|
||||||
<script setup lang="ts">
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
mockCreateGuestOrder: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/api/guestOrders', () => ({
|
|
||||||
createGuestOrder: mocks.mockCreateGuestOrder,
|
|
||||||
}))
|
|
||||||
|
|
||||||
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
|
|
||||||
import { createGuestOrder } from '@/api/guestOrders'
|
|
||||||
const mockCreateGuestOrder = vi.mocked(createGuestOrder)
|
|
||||||
|
|
||||||
function createTestRouter() {
|
|
||||||
return createRouter({
|
|
||||||
history: createMemoryHistory(),
|
|
||||||
routes: [
|
|
||||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
|
||||||
{
|
|
||||||
path: '/gast-kassa',
|
|
||||||
name: 'guest-checkout',
|
|
||||||
component: GuestCheckoutPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/gast-betalning/:orderId',
|
|
||||||
name: 'guest-payment',
|
|
||||||
component: { template: '<div>Payment</div>' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/logga-in',
|
|
||||||
name: 'login',
|
|
||||||
component: { template: '<div>Login</div>' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mountPage() {
|
|
||||||
const pinia = createPinia()
|
|
||||||
setActivePinia(pinia)
|
|
||||||
const router = createTestRouter()
|
|
||||||
await router.push({ name: 'guest-checkout' })
|
|
||||||
await router.isReady()
|
|
||||||
|
|
||||||
const wrapper = mount(GuestCheckoutPage, {
|
|
||||||
global: { plugins: [router, pinia] },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { wrapper, router }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GuestCheckoutPage', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders heading and price', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
expect(wrapper.text()).toContain('Skicka ett brev')
|
|
||||||
expect(wrapper.text()).toContain('49 kr')
|
|
||||||
expect(wrapper.text()).toContain('Inget konto behövs')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('disables submit button when fields are empty', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
const submit = wrapper.find('.guest-checkout__submit')
|
|
||||||
expect(submit.attributes('disabled')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('enables submit button when plate, letter, and email are valid', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
const inputs = wrapper.findAll('input')
|
|
||||||
const textarea = wrapper.find('textarea')
|
|
||||||
|
|
||||||
await inputs[0].setValue('ABC123')
|
|
||||||
await textarea.setValue('Hej, din bil står fel!')
|
|
||||||
await inputs[1].setValue('test@example.se')
|
|
||||||
|
|
||||||
const submit = wrapper.find('.guest-checkout__submit')
|
|
||||||
expect(submit.attributes('disabled')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps submit disabled with an invalid plate', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
const inputs = wrapper.findAll('input')
|
|
||||||
const textarea = wrapper.find('textarea')
|
|
||||||
|
|
||||||
await inputs[0].setValue('ABC') // too short
|
|
||||||
await textarea.setValue('Hej')
|
|
||||||
await inputs[1].setValue('test@example.se')
|
|
||||||
|
|
||||||
const submit = wrapper.find('.guest-checkout__submit')
|
|
||||||
expect(submit.attributes('disabled')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps submit disabled with an invalid email', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
const inputs = wrapper.findAll('input')
|
|
||||||
const textarea = wrapper.find('textarea')
|
|
||||||
|
|
||||||
await inputs[0].setValue('ABC123')
|
|
||||||
await textarea.setValue('Hej')
|
|
||||||
await inputs[1].setValue('not-an-email')
|
|
||||||
|
|
||||||
const submit = wrapper.find('.guest-checkout__submit')
|
|
||||||
expect(submit.attributes('disabled')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows character count that updates with input', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
const textarea = wrapper.find('textarea')
|
|
||||||
|
|
||||||
await textarea.setValue('Hej')
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('3 / 1000 tecken')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows link to login page', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
expect(wrapper.text()).toContain('Har du redan ett konto?')
|
|
||||||
expect(wrapper.text()).toContain('Logga in')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls createGuestOrder and navigates on submit', async () => {
|
|
||||||
mockCreateGuestOrder.mockResolvedValue({
|
|
||||||
id: 'order-123',
|
|
||||||
plate: 'ABC123',
|
|
||||||
letterText: 'Hej',
|
|
||||||
status: 'pending_payment',
|
|
||||||
trackingId: null,
|
|
||||||
amountPaid: null,
|
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
|
||||||
guestToken: 'token-abc',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { wrapper, router } = await mountPage()
|
|
||||||
const inputs = wrapper.findAll('input')
|
|
||||||
const textarea = wrapper.find('textarea')
|
|
||||||
|
|
||||||
await inputs[0].setValue('ABC123')
|
|
||||||
await textarea.setValue('Hej, din bil står fel!')
|
|
||||||
await inputs[1].setValue('test@example.se')
|
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit.prevent')
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(mockCreateGuestOrder).toHaveBeenCalledWith(
|
|
||||||
'ABC123',
|
|
||||||
'Hej, din bil står fel!',
|
|
||||||
'test@example.se',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(router.currentRoute.value.name).toBe('guest-payment')
|
|
||||||
expect(router.currentRoute.value.params.orderId).toBe('order-123')
|
|
||||||
expect(router.currentRoute.value.query.token).toBe('token-abc')
|
|
||||||
expect(router.currentRoute.value.query.plate).toBe('ABC123')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error message when order creation fails', async () => {
|
|
||||||
mockCreateGuestOrder.mockRejectedValue(new Error('Network error'))
|
|
||||||
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
const inputs = wrapper.findAll('input')
|
|
||||||
const textarea = wrapper.find('textarea')
|
|
||||||
|
|
||||||
await inputs[0].setValue('ABC123')
|
|
||||||
await textarea.setValue('Hej')
|
|
||||||
await inputs[1].setValue('test@example.se')
|
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit.prevent')
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain(
|
|
||||||
'Kunde inte skapa beställningen. Försök igen senare.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
mockFetchGuestOrder: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/api/guestOrders', () => ({
|
|
||||||
fetchGuestOrder: mocks.mockFetchGuestOrder,
|
|
||||||
}))
|
|
||||||
|
|
||||||
import GuestOrderPage from '@/pages/GuestOrderPage.vue'
|
|
||||||
|
|
||||||
const mockOrder = {
|
|
||||||
id: 'order-123',
|
|
||||||
plate: 'ABC123',
|
|
||||||
letterText: 'Hej, din bil står fel!',
|
|
||||||
status: 'pending_payment',
|
|
||||||
trackingId: null,
|
|
||||||
amountPaid: null,
|
|
||||||
createdAt: '2025-01-01T12:00:00Z',
|
|
||||||
guestToken: 'token-abc',
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestRouter() {
|
|
||||||
return createRouter({
|
|
||||||
history: createMemoryHistory(),
|
|
||||||
routes: [
|
|
||||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
|
||||||
{
|
|
||||||
path: '/gast-order/:token',
|
|
||||||
name: 'guest-order',
|
|
||||||
component: GuestOrderPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/gast-betalning/:orderId',
|
|
||||||
name: 'guest-payment',
|
|
||||||
component: { template: '<div>Payment</div>' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mountPage(token = 'token-abc') {
|
|
||||||
const pinia = createPinia()
|
|
||||||
setActivePinia(pinia)
|
|
||||||
const router = createTestRouter()
|
|
||||||
await router.push({ name: 'guest-order', params: { token } })
|
|
||||||
await router.isReady()
|
|
||||||
|
|
||||||
const wrapper = mount(GuestOrderPage, {
|
|
||||||
global: { plugins: [router, pinia] },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { wrapper, router }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GuestOrderPage', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows loading state initially', async () => {
|
|
||||||
// Never resolve so we can see the loading state
|
|
||||||
mocks.mockFetchGuestOrder.mockReturnValue(new Promise(() => {}))
|
|
||||||
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
expect(wrapper.text()).toContain('Laddar')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders order details after loading', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Din beställning')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('ABC123')
|
|
||||||
expect(wrapper.text()).toContain('order-123')
|
|
||||||
expect(wrapper.text()).toContain('Hej, din bil står fel!')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays human-readable status labels', async () => {
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'pending_payment',
|
|
||||||
})
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Väntar på betalning')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "Behandlas" for paid status', async () => {
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'paid',
|
|
||||||
})
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Behandlas')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "Skickat" for sent status', async () => {
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'sent',
|
|
||||||
})
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Skickat')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "Levererat" for delivered status', async () => {
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'delivered',
|
|
||||||
})
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Levererat')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "Misslyckades" for failed status', async () => {
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'failed',
|
|
||||||
})
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Misslyckades')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "Avbrutet" for cancelled status', async () => {
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'cancelled',
|
|
||||||
})
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Avbrutet')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows payment link when status is pending_payment', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Gå till betalningssidan')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides payment link when order is not pending_payment', async () => {
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'sent',
|
|
||||||
})
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).not.toContain('Gå till betalningssidan')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error when fetch fails', async () => {
|
|
||||||
mocks.mockFetchGuestOrder.mockRejectedValue(new Error('Not found'))
|
|
||||||
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain(
|
|
||||||
'Kunde inte hitta beställningen. Kontrollera länken.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not fetch order when token is missing in params', async () => {
|
|
||||||
// The component guards against an empty token in onMounted.
|
|
||||||
// Vue Router requires the :token param so we simulate by checking
|
|
||||||
// that fetchGuestOrder is only called once per mount with a token.
|
|
||||||
const { wrapper } = await mountPage('token-abc')
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Din beställning')
|
|
||||||
})
|
|
||||||
expect(mocks.mockFetchGuestOrder).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
mockFetchGuestOrder: vi.fn(),
|
|
||||||
mockPayGuestOrder: vi.fn(),
|
|
||||||
mockFetchSwishInfo: vi.fn(),
|
|
||||||
mockBuildSwishPaymentUrl: vi.fn(),
|
|
||||||
mockToDataURL: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/api/guestOrders', () => ({
|
|
||||||
fetchGuestOrder: mocks.mockFetchGuestOrder,
|
|
||||||
payGuestOrder: mocks.mockPayGuestOrder,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/api/payment', () => ({
|
|
||||||
fetchSwishInfo: mocks.mockFetchSwishInfo,
|
|
||||||
buildSwishPaymentUrl: mocks.mockBuildSwishPaymentUrl,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('qrcode', () => ({
|
|
||||||
default: {
|
|
||||||
toDataURL: mocks.mockToDataURL,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue'
|
|
||||||
import QRCode from 'qrcode'
|
|
||||||
|
|
||||||
const mockToDataURL = vi.mocked(QRCode.toDataURL)
|
|
||||||
|
|
||||||
const mockOrder = {
|
|
||||||
id: 'order-123',
|
|
||||||
plate: 'ABC123',
|
|
||||||
letterText: 'Hej',
|
|
||||||
status: 'pending_payment',
|
|
||||||
trackingId: null,
|
|
||||||
amountPaid: null,
|
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
|
||||||
guestToken: 'token-abc',
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestRouter() {
|
|
||||||
return createRouter({
|
|
||||||
history: createMemoryHistory(),
|
|
||||||
routes: [
|
|
||||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
|
||||||
{
|
|
||||||
path: '/gast-betalning/:orderId',
|
|
||||||
name: 'guest-payment',
|
|
||||||
component: GuestPaymentRedirect,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/gast-order/:token',
|
|
||||||
name: 'guest-order',
|
|
||||||
component: { template: '<div>Order</div>' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mountPage(
|
|
||||||
orderId = 'order-123',
|
|
||||||
token = 'token-abc',
|
|
||||||
plate = 'ABC123',
|
|
||||||
) {
|
|
||||||
const pinia = createPinia()
|
|
||||||
setActivePinia(pinia)
|
|
||||||
const router = createTestRouter()
|
|
||||||
await router.push({
|
|
||||||
name: 'guest-payment',
|
|
||||||
params: { orderId },
|
|
||||||
query: { token, plate },
|
|
||||||
})
|
|
||||||
await router.isReady()
|
|
||||||
|
|
||||||
const wrapper = mount(GuestPaymentRedirect, {
|
|
||||||
global: { plugins: [router, pinia] },
|
|
||||||
})
|
|
||||||
|
|
||||||
return { wrapper, router }
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupDefaultMocks() {
|
|
||||||
mocks.mockFetchSwishInfo.mockResolvedValue({
|
|
||||||
number: '0701234567',
|
|
||||||
amount: 49,
|
|
||||||
})
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
|
|
||||||
mocks.mockBuildSwishPaymentUrl.mockReturnValue(
|
|
||||||
'https://app.swish.nu/1/p/sw/?sw=46701234567&amt=49.00&msg=order-123',
|
|
||||||
)
|
|
||||||
mocks.mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GuestPaymentRedirect', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
setupDefaultMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders heading and plate', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Betalning')
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('ABC123')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays order id', async () => {
|
|
||||||
const { wrapper } = await mountPage('order-456')
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('order-456')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the amount to pay', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('49 kr')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders QR code after loading swish info', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
expect(mockToDataURL).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders Swish payment link', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
const link = wrapper.find('.payment__swish-link')
|
|
||||||
expect(link.exists()).toBe(true)
|
|
||||||
expect(link.attributes('href')).toContain('app.swish.nu')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays Swish number from API', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('0701234567')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the "Jag har betalat" button', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
|
||||||
expect(wrapper.text()).toContain('Jag har betalat')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows confirmation dialog when clicking "Jag har betalat"', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('.payment__submit').trigger('click')
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
|
|
||||||
expect(wrapper.text()).toContain('0701234567')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can cancel the confirmation dialog', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('.payment__submit').trigger('click')
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__confirm-cancel').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('.payment__confirm-cancel').trigger('click')
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Jag har betalat')
|
|
||||||
expect(wrapper.text()).not.toContain('Jag bekräftar')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls payGuestOrder on confirmation', async () => {
|
|
||||||
mocks.mockPayGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'processing',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('.payment__submit').trigger('click')
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
|
||||||
|
|
||||||
expect(mocks.mockPayGuestOrder).toHaveBeenCalledWith('token-abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('navigates to guest-order after successful payment', async () => {
|
|
||||||
mocks.mockPayGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'processing',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { wrapper, router } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('.payment__submit').trigger('click')
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(router.currentRoute.value.name).toBe('guest-order')
|
|
||||||
expect(router.currentRoute.value.params.token).toBe('token-abc')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error when payment confirmation fails', async () => {
|
|
||||||
mocks.mockPayGuestOrder.mockRejectedValue(new Error('Network error'))
|
|
||||||
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('.payment__submit').trigger('click')
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain(
|
|
||||||
'Kunde inte bekräfta betalningen. Försök igen.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error when swish info fetch fails', async () => {
|
|
||||||
mocks.mockFetchSwishInfo.mockRejectedValue(new Error('Network error'))
|
|
||||||
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain(
|
|
||||||
'Kunde inte ladda betalningsinformation. Försök igen senare.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error when token is missing', async () => {
|
|
||||||
const { wrapper } = await mountPage('order-123', '', 'ABC123')
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain(
|
|
||||||
'Saknar order-token. Gå tillbaka och försök igen.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
expect(mocks.mockFetchSwishInfo).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('redirects to guest-order when order is already paid', async () => {
|
|
||||||
mocks.mockFetchGuestOrder.mockResolvedValue({
|
|
||||||
...mockOrder,
|
|
||||||
status: 'processing',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { router } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(router.currentRoute.value.name).toBe('guest-order')
|
|
||||||
expect(router.currentRoute.value.params.token).toBe('token-abc')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the magic order link', async () => {
|
|
||||||
const { wrapper } = await mountPage()
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Din orderlänk')
|
|
||||||
})
|
|
||||||
expect(wrapper.find('.guest-payment__magic-url').text()).toContain(
|
|
||||||
'token-abc',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { 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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
mockRequest: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
|
||||||
request: mocks.mockRequest,
|
|
||||||
}))
|
|
||||||
|
|
||||||
import {
|
|
||||||
createGuestOrder,
|
|
||||||
fetchGuestOrder,
|
|
||||||
payGuestOrder,
|
|
||||||
} from '@/api/guestOrders'
|
|
||||||
|
|
||||||
describe('guestOrders API', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
mocks.mockRequest.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('createGuestOrder', () => {
|
|
||||||
it('sends a POST to /guest-orders with plate, letterText, and email', async () => {
|
|
||||||
const mockOrder = {
|
|
||||||
id: 'order-1',
|
|
||||||
plate: 'ABC123',
|
|
||||||
letterText: 'Hej',
|
|
||||||
status: 'pending_payment',
|
|
||||||
trackingId: null,
|
|
||||||
amountPaid: null,
|
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
|
||||||
guestToken: 'token-abc',
|
|
||||||
}
|
|
||||||
mocks.mockRequest.mockResolvedValue(mockOrder)
|
|
||||||
|
|
||||||
const result = await createGuestOrder('ABC123', 'Hej', 'test@example.se')
|
|
||||||
|
|
||||||
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
plate: 'ABC123',
|
|
||||||
letterText: 'Hej',
|
|
||||||
email: 'test@example.se',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
expect(result).toEqual(mockOrder)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('propagates errors from the API client', async () => {
|
|
||||||
mocks.mockRequest.mockRejectedValue(new Error('Server error'))
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
createGuestOrder('ABC123', 'Hej', 'test@example.se'),
|
|
||||||
).rejects.toThrow('Server error')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('fetchGuestOrder', () => {
|
|
||||||
it('sends a GET to /guest-orders/:token', async () => {
|
|
||||||
const mockOrder = {
|
|
||||||
id: 'order-1',
|
|
||||||
plate: 'ABC123',
|
|
||||||
letterText: 'Hej',
|
|
||||||
status: 'pending_payment',
|
|
||||||
trackingId: null,
|
|
||||||
amountPaid: null,
|
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
|
||||||
guestToken: 'token-abc',
|
|
||||||
}
|
|
||||||
mocks.mockRequest.mockResolvedValue(mockOrder)
|
|
||||||
|
|
||||||
const result = await fetchGuestOrder('token-abc')
|
|
||||||
|
|
||||||
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders/token-abc')
|
|
||||||
expect(result).toEqual(mockOrder)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('payGuestOrder', () => {
|
|
||||||
it('sends a POST to /guest-orders/:token/pay', async () => {
|
|
||||||
const mockOrder = {
|
|
||||||
id: 'order-1',
|
|
||||||
plate: 'ABC123',
|
|
||||||
letterText: 'Hej',
|
|
||||||
status: 'processing',
|
|
||||||
trackingId: null,
|
|
||||||
amountPaid: 49,
|
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
|
||||||
guestToken: 'token-abc',
|
|
||||||
}
|
|
||||||
mocks.mockRequest.mockResolvedValue(mockOrder)
|
|
||||||
|
|
||||||
const result = await payGuestOrder('token-abc')
|
|
||||||
|
|
||||||
expect(mocks.mockRequest).toHaveBeenCalledWith(
|
|
||||||
'/guest-orders/token-abc/pay',
|
|
||||||
{ method: 'POST' },
|
|
||||||
)
|
|
||||||
expect(result).toEqual(mockOrder)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { buildSwishPaymentUrl } from '@/api/payment'
|
|
||||||
|
|
||||||
describe('buildSwishPaymentUrl', () => {
|
|
||||||
it('normalises Swedish national format to international', () => {
|
|
||||||
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
|
|
||||||
'sw=46701234567',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('strips a leading + from international format', () => {
|
|
||||||
const url = buildSwishPaymentUrl('+46701234567', 49, 'test')
|
|
||||||
expect(url).toContain('sw=46701234567')
|
|
||||||
expect(url).not.toContain('sw=%2B')
|
|
||||||
expect(url).not.toContain('sw=+')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves already-international numbers unchanged', () => {
|
|
||||||
expect(buildSwishPaymentUrl('46701234567', 49, 'test')).toContain(
|
|
||||||
'sw=46701234567',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves Swish Business numbers (123…) unchanged', () => {
|
|
||||||
expect(buildSwishPaymentUrl('1234567890', 49, 'test')).toContain(
|
|
||||||
'sw=1234567890',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('strips whitespace from the number', () => {
|
|
||||||
expect(buildSwishPaymentUrl('070 123 45 67', 49, 'test')).toContain(
|
|
||||||
'sw=46701234567',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes the amount with two decimal places in amt', () => {
|
|
||||||
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
|
|
||||||
'amt=49.00',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('URL-encodes the message in the msg parameter', () => {
|
|
||||||
const url = buildSwishPaymentUrl('0701234567', 49, 'ABC 123')
|
|
||||||
expect(url).toContain('msg=ABC+123')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses the correct Swish C2B base URL', () => {
|
|
||||||
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
|
|
||||||
'https://app.swish.nu/1/p/sw/?',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { request } from './client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guest order — placed and paid for without an account. {@link guestToken}
|
|
||||||
* is the only credential the client holds; it is returned only on create
|
|
||||||
* and from a by-token lookup. Pass it as the query string on the next
|
|
||||||
* page so a refresh keeps the session alive.
|
|
||||||
*/
|
|
||||||
export interface GuestOrder {
|
|
||||||
id: string
|
|
||||||
plate: string
|
|
||||||
letterText: string
|
|
||||||
status: string
|
|
||||||
trackingId: string | null
|
|
||||||
amountPaid: number | null
|
|
||||||
createdAt: string
|
|
||||||
guestToken: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGuestOrder(
|
|
||||||
plate: string,
|
|
||||||
letterText: string,
|
|
||||||
email: string,
|
|
||||||
): Promise<GuestOrder> {
|
|
||||||
return request<GuestOrder>('/guest-orders', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ plate, letterText, email }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchGuestOrder(token: string): Promise<GuestOrder> {
|
|
||||||
return request<GuestOrder>(`/guest-orders/${token}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function payGuestOrder(token: string): Promise<GuestOrder> {
|
|
||||||
return request<GuestOrder>(`/guest-orders/${token}/pay`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -15,47 +15,3 @@ export function payOrder(orderId: string): Promise<Order> {
|
||||||
export function fetchSwishInfo(): Promise<SwishInfo> {
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
/**
|
|
||||||
* useSeo — dynamically updates document <title> and meta tags on route change.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const seo = useSeo()
|
|
||||||
* // On page mount / watch:
|
|
||||||
* watch(route, () => {
|
|
||||||
* seo.set({ title: 'Min sida', description: '...' })
|
|
||||||
* })
|
|
||||||
*
|
|
||||||
* The composable sets <title>, meta[name=description], meta[name=keywords],
|
|
||||||
* meta[property=og:title], meta[property=og:description], meta[name=twitter:title],
|
|
||||||
* and meta[name=twitter:description].
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface SeoMeta {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
keywords?: string
|
|
||||||
ogTitle?: string
|
|
||||||
ogDescription?: string
|
|
||||||
canonical?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSeo() {
|
|
||||||
function set(meta: SeoMeta) {
|
|
||||||
const title = meta.title ?? 'Bilhej'
|
|
||||||
const description =
|
|
||||||
meta.description ??
|
|
||||||
'Skicka brev till fordonsägare via registreringsnummer.'
|
|
||||||
|
|
||||||
// Document title
|
|
||||||
document.title = title
|
|
||||||
|
|
||||||
// Common meta tags
|
|
||||||
updateMeta('description', description)
|
|
||||||
if (meta.keywords) updateMeta('keywords', meta.keywords)
|
|
||||||
|
|
||||||
// Open Graph
|
|
||||||
updateMeta('og:title', meta.ogTitle ?? title, 'property')
|
|
||||||
updateMeta('og:description', meta.ogDescription ?? description, 'property')
|
|
||||||
|
|
||||||
// Twitter
|
|
||||||
updateMeta('twitter:title', meta.ogTitle ?? title, 'name')
|
|
||||||
updateMeta('twitter:description', meta.ogDescription ?? description, 'name')
|
|
||||||
|
|
||||||
// Canonical URL
|
|
||||||
updateCanonical(meta.canonical)
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
document.title = 'Bilhej — Skicka brev till fordonsägare'
|
|
||||||
updateMeta(
|
|
||||||
'description',
|
|
||||||
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
|
|
||||||
)
|
|
||||||
updateMeta('og:title', 'Bilhej — Skicka brev till fordonsägare', 'property')
|
|
||||||
updateMeta(
|
|
||||||
'og:description',
|
|
||||||
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
|
|
||||||
'property',
|
|
||||||
)
|
|
||||||
updateCanonical('https://bilhej.se/')
|
|
||||||
}
|
|
||||||
|
|
||||||
return { set, reset }
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMeta(
|
|
||||||
nameOrProperty: string,
|
|
||||||
content: string,
|
|
||||||
attr: 'name' | 'property' = 'name',
|
|
||||||
) {
|
|
||||||
let el = document.querySelector(
|
|
||||||
`meta[${attr}="${nameOrProperty}"]`,
|
|
||||||
) as HTMLMetaElement | null
|
|
||||||
if (!el) {
|
|
||||||
el = document.createElement('meta')
|
|
||||||
el.setAttribute(attr, nameOrProperty)
|
|
||||||
document.head.appendChild(el)
|
|
||||||
}
|
|
||||||
el.setAttribute('content', content)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCanonical(href: string | undefined) {
|
|
||||||
let link = document.querySelector(
|
|
||||||
'link[rel="canonical"]',
|
|
||||||
) as HTMLLinkElement | null
|
|
||||||
if (!link) {
|
|
||||||
link = document.createElement('link')
|
|
||||||
link.setAttribute('rel', 'canonical')
|
|
||||||
document.head.appendChild(link)
|
|
||||||
}
|
|
||||||
link.setAttribute('href', href ?? 'https://bilhej.se/')
|
|
||||||
}
|
|
||||||
|
|
@ -1,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.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,8 +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'
|
import { isSessionExpired } from '@/api/client'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -14,13 +13,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 +21,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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -108,37 +78,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 +201,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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue