Merge origin/master into feature/account-settings-dropdown.
Resolve router conflict: keep /bekrafta-epost confirm route alongside master's /om-oss about page and /om redirect. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
b2aaeb5733
34 changed files with 3691 additions and 327 deletions
|
|
@ -446,7 +446,7 @@ Gross margin: 14 SEK
|
||||||
| Is a license plate personal data? | Yes (it directly identifies a vehicle owner). |
|
| Is a license plate personal data? | Yes (it directly identifies a vehicle owner). |
|
||||||
| Is an address personal data? | Yes. |
|
| Is an address personal data? | Yes. |
|
||||||
| What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. |
|
| What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. |
|
||||||
| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se"_ |
|
| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se"_ |
|
||||||
|
|
||||||
### 11.2 Transportstyrelsen Access
|
### 11.2 Transportstyrelsen Access
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,15 @@ import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import se.bilhalsning.dto.CreateOrderRequest;
|
import se.bilhalsning.dto.CreateOrderRequest;
|
||||||
import se.bilhalsning.dto.OrderResponse;
|
import se.bilhalsning.dto.OrderResponse;
|
||||||
|
import se.bilhalsning.dto.UpdateOrderRequest;
|
||||||
import se.bilhalsning.entity.Order;
|
import se.bilhalsning.entity.Order;
|
||||||
import se.bilhalsning.entity.User;
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||||
|
|
@ -20,6 +23,7 @@ import se.bilhalsning.service.OrderService;
|
||||||
import se.bilhalsning.service.UserService;
|
import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/orders")
|
@RequestMapping("/api/orders")
|
||||||
|
|
@ -41,6 +45,21 @@ public class OrderController {
|
||||||
return ResponseEntity.ok(orders);
|
return ResponseEntity.ok(orders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<OrderResponse> get(@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails) {
|
||||||
|
User user = userService.findByEmail(userDetails.getUsername())
|
||||||
|
.orElseThrow(InvalidCredentialsException::new);
|
||||||
|
|
||||||
|
Order order = orderService.getOrderById(id);
|
||||||
|
|
||||||
|
if (!order.getUserId().equals(user.getId())) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(toResponse(order));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<OrderResponse> create(
|
public ResponseEntity<OrderResponse> create(
|
||||||
@Valid @RequestBody CreateOrderRequest request,
|
@Valid @RequestBody CreateOrderRequest request,
|
||||||
|
|
@ -57,6 +76,31 @@ public class OrderController {
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
|
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
public ResponseEntity<OrderResponse> update(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody UpdateOrderRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails) {
|
||||||
|
User user = userService.findByEmail(userDetails.getUsername())
|
||||||
|
.orElseThrow(InvalidCredentialsException::new);
|
||||||
|
|
||||||
|
Order order = orderService.updatePendingOrder(id, user.getId(), request.letterText());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(toResponse(order));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/cancel")
|
||||||
|
public ResponseEntity<OrderResponse> cancel(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails) {
|
||||||
|
User user = userService.findByEmail(userDetails.getUsername())
|
||||||
|
.orElseThrow(InvalidCredentialsException::new);
|
||||||
|
|
||||||
|
Order order = orderService.cancelOrder(id, user.getId());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(toResponse(order));
|
||||||
|
}
|
||||||
|
|
||||||
private OrderResponse toResponse(Order order) {
|
private OrderResponse toResponse(Order order) {
|
||||||
return new OrderResponse(
|
return new OrderResponse(
|
||||||
order.getId(),
|
order.getId(),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import java.util.UUID;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
@ -12,28 +14,38 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import se.bilhalsning.dto.OrderResponse;
|
import se.bilhalsning.dto.OrderResponse;
|
||||||
import se.bilhalsning.entity.Order;
|
import se.bilhalsning.entity.Order;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||||
import se.bilhalsning.service.OrderService;
|
import se.bilhalsning.service.OrderService;
|
||||||
|
import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/payment")
|
@RequestMapping("/api/payment")
|
||||||
public class PaymentController {
|
public class PaymentController {
|
||||||
|
|
||||||
private final OrderService orderService;
|
private final OrderService orderService;
|
||||||
|
private final UserService userService;
|
||||||
private final String swishNumber;
|
private final String swishNumber;
|
||||||
private final int letterPrice;
|
private final int letterPrice;
|
||||||
|
|
||||||
public PaymentController(
|
public PaymentController(
|
||||||
OrderService orderService,
|
OrderService orderService,
|
||||||
|
UserService userService,
|
||||||
@Value("${app.payment.swish-number}") String swishNumber,
|
@Value("${app.payment.swish-number}") String swishNumber,
|
||||||
@Value("${app.payment.letter-price}") int letterPrice) {
|
@Value("${app.payment.letter-price}") int letterPrice) {
|
||||||
this.orderService = orderService;
|
this.orderService = orderService;
|
||||||
|
this.userService = userService;
|
||||||
this.swishNumber = swishNumber;
|
this.swishNumber = swishNumber;
|
||||||
this.letterPrice = letterPrice;
|
this.letterPrice = letterPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{orderId}/pay")
|
@PostMapping("/{orderId}/pay")
|
||||||
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
|
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId,
|
||||||
Order order = orderService.confirmPayment(orderId);
|
@AuthenticationPrincipal UserDetails userDetails) {
|
||||||
|
User user = userService.findByEmail(userDetails.getUsername())
|
||||||
|
.orElseThrow(InvalidCredentialsException::new);
|
||||||
|
|
||||||
|
Order order = orderService.confirmPayment(orderId, user.getId());
|
||||||
return ResponseEntity.ok(toResponse(order));
|
return ResponseEntity.ok(toResponse(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package se.bilhalsning.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record UpdateOrderRequest(
|
||||||
|
@NotBlank(message = "Brevtext krävs")
|
||||||
|
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
|
||||||
|
String letterText
|
||||||
|
) {}
|
||||||
|
|
@ -6,7 +6,8 @@ public enum OrderStatus {
|
||||||
PROCESSING("processing"),
|
PROCESSING("processing"),
|
||||||
SENT("sent"),
|
SENT("sent"),
|
||||||
DELIVERED("delivered"),
|
DELIVERED("delivered"),
|
||||||
FAILED("failed");
|
FAILED("failed"),
|
||||||
|
CANCELLED("cancelled");
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,13 @@ public class GlobalExceptionHandler {
|
||||||
.body(new ErrorResponse("E-postadressen är redan registrerad"));
|
.body(new ErrorResponse("E-postadressen är redan registrerad"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(InvalidOrderStateException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleInvalidOrderState(InvalidOrderStateException ex) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.CONFLICT)
|
||||||
|
.body(new ErrorResponse(ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(OrderNotFoundException.class)
|
@ExceptionHandler(OrderNotFoundException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
|
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package se.bilhalsning.exception;
|
||||||
|
|
||||||
|
public class InvalidOrderStateException extends RuntimeException {
|
||||||
|
public InvalidOrderStateException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import se.bilhalsning.entity.Order;
|
import se.bilhalsning.entity.Order;
|
||||||
import se.bilhalsning.entity.OrderStatus;
|
import se.bilhalsning.entity.OrderStatus;
|
||||||
|
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||||
import se.bilhalsning.exception.OrderNotFoundException;
|
import se.bilhalsning.exception.OrderNotFoundException;
|
||||||
import se.bilhalsning.repository.OrderRepository;
|
import se.bilhalsning.repository.OrderRepository;
|
||||||
|
|
||||||
|
|
@ -55,11 +56,37 @@ public class OrderService {
|
||||||
return orderRepository.save(order);
|
return orderRepository.save(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Order confirmPayment(UUID orderId) {
|
public Order confirmPayment(UUID orderId, UUID userId) {
|
||||||
Order order = orderRepository.findById(orderId)
|
Order order = requirePendingOwnedBy(orderId, userId);
|
||||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
|
||||||
|
|
||||||
order.setStatus(OrderStatus.PROCESSING);
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
return orderRepository.save(order);
|
return orderRepository.save(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Order cancelOrder(UUID orderId, UUID userId) {
|
||||||
|
Order order = requirePendingOwnedBy(orderId, userId);
|
||||||
|
order.setStatus(OrderStatus.CANCELLED);
|
||||||
|
return orderRepository.save(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order updatePendingOrder(UUID orderId, UUID userId, String letterText) {
|
||||||
|
Order order = requirePendingOwnedBy(orderId, userId);
|
||||||
|
order.setLetterText(letterText);
|
||||||
|
return orderRepository.save(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
|
||||||
|
Order order = orderRepository.findById(orderId)
|
||||||
|
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||||
|
|
||||||
|
if (!order.getUserId().equals(userId)) {
|
||||||
|
throw new OrderNotFoundException(orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
|
||||||
|
throw new InvalidOrderStateException(
|
||||||
|
"Beställningen kan inte ändras i detta tillstånd");
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package se.bilhalsning.controller;
|
package se.bilhalsning.controller;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
@ -163,4 +165,115 @@ class OrderControllerTest {
|
||||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
|
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "test@bilhej.se")
|
||||||
|
void shouldGetSingleOrderForOwner() throws Exception {
|
||||||
|
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
user.setEmail("test@bilhej.se");
|
||||||
|
|
||||||
|
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setPlate("ABC123");
|
||||||
|
order.setLetterText("Test letter");
|
||||||
|
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
|
||||||
|
|
||||||
|
when(orderService.getOrderById(orderId)).thenReturn(order);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/orders/" + orderId))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||||
|
.andExpect(jsonPath("$.plate").value("ABC123"))
|
||||||
|
.andExpect(jsonPath("$.status").value("pending_payment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "test@bilhej.se")
|
||||||
|
void shouldReturn404WhenGettingOtherUsersOrder() throws Exception {
|
||||||
|
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
user.setEmail("test@bilhej.se");
|
||||||
|
|
||||||
|
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(UUID.randomUUID());
|
||||||
|
|
||||||
|
when(orderService.getOrderById(orderId)).thenReturn(order);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/orders/" + orderId))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "test@bilhej.se")
|
||||||
|
void shouldPatchOrderSuccessfully() throws Exception {
|
||||||
|
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
user.setEmail("test@bilhej.se");
|
||||||
|
|
||||||
|
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setPlate("ABC123");
|
||||||
|
order.setLetterText("Updated text");
|
||||||
|
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
|
||||||
|
|
||||||
|
when(orderService.updatePendingOrder(any(), any(), any())).thenReturn(order);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/orders/" + orderId)
|
||||||
|
.contentType("application/json")
|
||||||
|
.content("{\"letterText\":\"Updated text\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.letterText").value("Updated text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "test@bilhej.se")
|
||||||
|
void shouldRejectPatchWithEmptyLetterText() throws Exception {
|
||||||
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/orders/" + orderId)
|
||||||
|
.contentType("application/json")
|
||||||
|
.content("{\"letterText\":\"\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "test@bilhej.se")
|
||||||
|
void shouldCancelOrderSuccessfully() throws Exception {
|
||||||
|
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
user.setEmail("test@bilhej.se");
|
||||||
|
|
||||||
|
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setPlate("ABC123");
|
||||||
|
order.setLetterText("Test letter");
|
||||||
|
order.setStatus(se.bilhalsning.entity.OrderStatus.CANCELLED);
|
||||||
|
|
||||||
|
when(orderService.cancelOrder(orderId, userId)).thenReturn(order);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/orders/" + orderId + "/cancel"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("cancelled"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package se.bilhalsning.controller;
|
package se.bilhalsning.controller;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
|
@ -7,6 +8,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
@ -18,8 +20,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import se.bilhalsning.entity.Order;
|
import se.bilhalsning.entity.Order;
|
||||||
import se.bilhalsning.entity.OrderStatus;
|
import se.bilhalsning.entity.OrderStatus;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.exception.OrderNotFoundException;
|
import se.bilhalsning.exception.OrderNotFoundException;
|
||||||
import se.bilhalsning.service.OrderService;
|
import se.bilhalsning.service.OrderService;
|
||||||
|
import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
|
|
@ -31,6 +35,9 @@ class PaymentControllerTest {
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private OrderService orderService;
|
private OrderService orderService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/payment/{orderId}/pay",
|
mockMvc.perform(post("/api/payment/{orderId}/pay",
|
||||||
|
|
@ -42,12 +49,19 @@ class PaymentControllerTest {
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhej.se")
|
||||||
void shouldConfirmPaymentSuccessfully() throws Exception {
|
void shouldConfirmPaymentSuccessfully() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
user.setEmail("test@bilhej.se");
|
||||||
|
|
||||||
|
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
Order order = new Order();
|
Order order = new Order();
|
||||||
order.setId(orderId);
|
order.setId(orderId);
|
||||||
order.setPlate("ABC123");
|
order.setPlate("ABC123");
|
||||||
order.setStatus(OrderStatus.PROCESSING);
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
|
|
||||||
when(orderService.confirmPayment(eq(orderId))).thenReturn(order);
|
when(orderService.confirmPayment(eq(orderId), eq(userId))).thenReturn(order);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
|
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
|
||||||
.contentType(MediaType.APPLICATION_JSON))
|
.contentType(MediaType.APPLICATION_JSON))
|
||||||
|
|
@ -60,7 +74,14 @@ class PaymentControllerTest {
|
||||||
@WithMockUser(username = "test@bilhej.se")
|
@WithMockUser(username = "test@bilhej.se")
|
||||||
void shouldReturn404WhenOrderNotFound() throws Exception {
|
void shouldReturn404WhenOrderNotFound() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
when(orderService.confirmPayment(eq(orderId)))
|
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
user.setEmail("test@bilhej.se");
|
||||||
|
|
||||||
|
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
when(orderService.confirmPayment(eq(orderId), eq(userId)))
|
||||||
.thenThrow(new OrderNotFoundException(orderId));
|
.thenThrow(new OrderNotFoundException(orderId));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
|
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import se.bilhalsning.entity.Order;
|
import se.bilhalsning.entity.Order;
|
||||||
import se.bilhalsning.entity.OrderStatus;
|
import se.bilhalsning.entity.OrderStatus;
|
||||||
|
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||||
import se.bilhalsning.exception.OrderNotFoundException;
|
import se.bilhalsning.exception.OrderNotFoundException;
|
||||||
import se.bilhalsning.repository.OrderRepository;
|
import se.bilhalsning.repository.OrderRepository;
|
||||||
|
|
||||||
|
|
@ -127,4 +128,125 @@ class OrderServiceTest {
|
||||||
assertThrows(OrderNotFoundException.class,
|
assertThrows(OrderNotFoundException.class,
|
||||||
() -> orderService.getOrderById(orderId));
|
() -> orderService.getOrderById(orderId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCancelOrderWhenPendingPayment() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||||
|
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = orderService.cancelOrder(orderId, userId);
|
||||||
|
|
||||||
|
assertEquals(OrderStatus.CANCELLED, result.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowWhenCancellingNonPendingOrder() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
|
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
|
||||||
|
assertThrows(InvalidOrderStateException.class,
|
||||||
|
() -> orderService.cancelOrder(orderId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowWhenCancellingOtherUsersOrder() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID otherUserId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(ownerId);
|
||||||
|
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||||
|
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
|
||||||
|
assertThrows(OrderNotFoundException.class,
|
||||||
|
() -> orderService.cancelOrder(orderId, otherUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUpdatePendingOrderLetterText() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||||
|
order.setLetterText("Old text");
|
||||||
|
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = orderService.updatePendingOrder(orderId, userId, "New text");
|
||||||
|
|
||||||
|
assertEquals("New text", result.getLetterText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowWhenUpdatingNonPendingOrder() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
|
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
|
||||||
|
assertThrows(InvalidOrderStateException.class,
|
||||||
|
() -> orderService.updatePendingOrder(orderId, userId, "New text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldConfirmPaymentForPendingOrder() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||||
|
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = orderService.confirmPayment(orderId, userId);
|
||||||
|
|
||||||
|
assertEquals(OrderStatus.PROCESSING, result.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowWhenConfirmingPaymentForNonPendingOrder() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(userId);
|
||||||
|
order.setStatus(OrderStatus.CANCELLED);
|
||||||
|
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
|
||||||
|
assertThrows(InvalidOrderStateException.class,
|
||||||
|
() -> orderService.confirmPayment(orderId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowWhenConfirmingPaymentForOtherUsersOrder() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID otherUserId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setUserId(ownerId);
|
||||||
|
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||||
|
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
|
||||||
|
assertThrows(OrderNotFoundException.class,
|
||||||
|
() -> orderService.confirmPayment(orderId, otherUserId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,30 @@ Fallback: reset links still log when `MAIL_HOST` is empty.
|
||||||
|
|
||||||
Keep using Mailpit (`docker compose up`, http://localhost:8025). Do not point local Docker at
|
Keep using Mailpit (`docker compose up`, http://localhost:8025). Do not point local Docker at
|
||||||
Resend unless you intend to send real mail.
|
Resend unless you intend to send real mail.
|
||||||
|
|
||||||
|
## 5. Inbound email on bilhej.se
|
||||||
|
|
||||||
|
Inbound mail uses **Resend Receiving** on the root domain `bilhej.se`. No mailbox is created in
|
||||||
|
Strato; the MX record routes all `@bilhej.se` addresses to Resend. You do not create each address
|
||||||
|
separately in Resend.
|
||||||
|
|
||||||
|
**Setup (done once):**
|
||||||
|
|
||||||
|
1. Resend → **Domains** → `bilhej.se` → enable **Receiving**
|
||||||
|
2. Strato → **DNS** → add the receiving MX record (e.g. `inbound-smtp.eu-west-1.amazonaws.com`)
|
||||||
|
3. Wait until Resend shows receiving as **Verified**
|
||||||
|
4. Send test mail to `support@bilhej.se` and `kontakt@bilhej.se`; confirm both appear under **Emails → Receiving**
|
||||||
|
|
||||||
|
**Reading mail:** open the [Resend Receiving inbox](https://resend.com/emails/receiving). There is
|
||||||
|
no automatic forward to Gmail unless you add a webhook handler later.
|
||||||
|
|
||||||
|
| Address | Purpose | Where mail goes |
|
||||||
|
|---------|---------|-----------------|
|
||||||
|
| `support@bilhej.se` | Orders, Swish, status, technical issues | Resend dashboard |
|
||||||
|
| `kontakt@bilhej.se` | General contact, printed letter footer | Resend dashboard |
|
||||||
|
| `klagomal@bilhej.se` | Complaints (shown on `/kontakt`) | Resend dashboard |
|
||||||
|
| `noreply@bilhej.se` | Outbound only (password reset) | Not an inbox |
|
||||||
|
|
||||||
|
**Optional later (same Resend inbox, no extra DNS):** `abuse@bilhej.se` if you want a published
|
||||||
|
address for misuse reports; `privacy@bilhej.se` if integritetspolicy asks for a dedicated
|
||||||
|
data-protection contact.
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,15 @@ test.describe('Deferred payment and admin lookup', () => {
|
||||||
const orderCard = page.locator('.orders__card', { hasText: orderId })
|
const orderCard = page.locator('.orders__card', { hasText: orderId })
|
||||||
await expect(orderCard.getByText(plate)).toBeVisible()
|
await expect(orderCard.getByText(plate)).toBeVisible()
|
||||||
await expect(orderCard.locator('.badge')).toHaveText('Väntar på betalning')
|
await expect(orderCard.locator('.badge')).toHaveText('Väntar på betalning')
|
||||||
await expect(orderCard.getByRole('link', { name: 'Betala nu' })).toBeVisible()
|
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible()
|
||||||
|
|
||||||
await orderCard.getByRole('link', { name: 'Betala nu' }).click()
|
await orderCard.getByRole('link', { name: 'Betala 49 kr' }).click()
|
||||||
await expect(page).toHaveURL(new RegExp(`/betalning/${orderId}`))
|
await expect(page).toHaveURL(new RegExp(`/betalning/${orderId}`))
|
||||||
await completeSwishPayment(page)
|
await completeSwishPayment(page)
|
||||||
|
|
||||||
await expect(page).toHaveURL('/orders')
|
await expect(page).toHaveURL('/orders')
|
||||||
await expect(orderCard.locator('.badge')).toHaveText('Hanteras')
|
await expect(orderCard.locator('.badge')).toHaveText('Hanteras')
|
||||||
await expect(orderCard.getByRole('link', { name: 'Betala nu' })).not.toBeVisible()
|
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('admin finds paid order under Att göra when searching partial order id', async ({
|
test('admin finds paid order under Att göra when searching partial order id', async ({
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ test.describe('Order history', () => {
|
||||||
await page.goto('/orders')
|
await page.goto('/orders')
|
||||||
|
|
||||||
const unpaidCard = page.locator('.orders__card', { hasText: 'DEF456' })
|
const unpaidCard = page.locator('.orders__card', { hasText: 'DEF456' })
|
||||||
await expect(unpaidCard.getByRole('link', { name: 'Betala nu' })).toBeVisible()
|
await expect(unpaidCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible()
|
||||||
await unpaidCard.getByRole('link', { name: 'Betala nu' }).click()
|
await unpaidCard.getByRole('link', { name: 'Betala 49 kr' }).click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/betalning\/c2eebc99/)
|
await expect(page).toHaveURL(/\/betalning\/c2eebc99/)
|
||||||
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible()
|
||||||
|
|
@ -91,4 +91,38 @@ test.describe('Order history', () => {
|
||||||
const trackingLink2 = page.getByRole('link', { name: 'PN987654321' })
|
const trackingLink2 = page.getByRole('link', { name: 'PN987654321' })
|
||||||
await expect(trackingLink2).toBeVisible()
|
await expect(trackingLink2).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('can cancel pending order', async ({ page }) => {
|
||||||
|
const plate = 'CAN999'
|
||||||
|
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
||||||
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
|
await page.waitForURL('/')
|
||||||
|
|
||||||
|
await page.goto(`/compose?plate=${plate}`)
|
||||||
|
await page.getByLabel('Ditt meddelande').fill('E2E-test: ska kunna avbrytas.')
|
||||||
|
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/betalning\//)
|
||||||
|
|
||||||
|
await page.goto('/orders')
|
||||||
|
|
||||||
|
const pendingCard = page.locator('.orders__card', { hasText: plate })
|
||||||
|
await expect(pendingCard.getByText('Väntar på betalning')).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
pendingCard.getByRole('link', { name: 'Betala 49 kr' }),
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
page.once('dialog', (dialog) => dialog.accept())
|
||||||
|
await pendingCard.getByRole('button', { name: 'Avbryt beställning' }).click()
|
||||||
|
|
||||||
|
await expect(pendingCard.getByText('Avbruten')).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
pendingCard.getByRole('link', { name: 'Betala 49 kr' }),
|
||||||
|
).not.toBeVisible()
|
||||||
|
await expect(
|
||||||
|
pendingCard.getByRole('button', { name: 'Avbryt beställning' }),
|
||||||
|
).not.toBeVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,44 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
import AboutPage from '@/pages/AboutPage.vue'
|
import AboutPage from '@/pages/AboutPage.vue'
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/om-oss', name: 'about', component: AboutPage },
|
||||||
|
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('AboutPage', () => {
|
describe('AboutPage', () => {
|
||||||
it('renders heading', () => {
|
it('renders heading and lead', () => {
|
||||||
const wrapper = mount(AboutPage)
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(AboutPage, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
expect(wrapper.text()).toContain('Om Bilhej')
|
expect(wrapper.text()).toContain('Om Bilhej')
|
||||||
|
expect(wrapper.text()).toContain('Bilhej gör det enkelt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders how-it-works steps', () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(AboutPage, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Skriv brevet här')
|
||||||
|
expect(wrapper.text()).toContain('Vi postar åt dig')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links to home page', () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(AboutPage, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
const cta = wrapper.find('a.about__cta-btn')
|
||||||
|
expect(cta.exists()).toBe(true)
|
||||||
|
expect(cta.attributes('href')).toBe('/')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,14 @@ function createTestRouter() {
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/om',
|
path: '/om-oss',
|
||||||
name: 'about',
|
name: 'about',
|
||||||
component: { template: '<div>About</div>' },
|
component: { template: '<div>About</div>' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/om',
|
||||||
|
redirect: '/om-oss',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/kontakt',
|
path: '/kontakt',
|
||||||
name: 'contact',
|
name: 'contact',
|
||||||
|
|
@ -40,7 +44,7 @@ describe('AppFooter', () => {
|
||||||
const links = wrapper.findAll('a')
|
const links = wrapper.findAll('a')
|
||||||
|
|
||||||
expect(links[0].text()).toBe('Om oss')
|
expect(links[0].text()).toBe('Om oss')
|
||||||
expect(links[0].attributes('href')).toBe('/om')
|
expect(links[0].attributes('href')).toBe('/om-oss')
|
||||||
|
|
||||||
expect(links[1].text()).toBe('Kontakt')
|
expect(links[1].text()).toBe('Kontakt')
|
||||||
expect(links[1].attributes('href')).toBe('/kontakt')
|
expect(links[1].attributes('href')).toBe('/kontakt')
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,32 @@ import { mount } from '@vue/test-utils'
|
||||||
import ContactPage from '@/pages/ContactPage.vue'
|
import ContactPage from '@/pages/ContactPage.vue'
|
||||||
|
|
||||||
describe('ContactPage', () => {
|
describe('ContactPage', () => {
|
||||||
it('renders heading', () => {
|
it('renders heading and lead', () => {
|
||||||
const wrapper = mount(ContactPage)
|
const wrapper = mount(ContactPage)
|
||||||
expect(wrapper.text()).toContain('Kontakta oss')
|
expect(wrapper.text()).toContain('Kontakta oss')
|
||||||
|
expect(wrapper.text()).toContain('klagomål')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders support email', () => {
|
||||||
|
const wrapper = mount(ContactPage)
|
||||||
|
expect(wrapper.text()).toContain('support@bilhej.se')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders general contact email', () => {
|
||||||
|
const wrapper = mount(ContactPage)
|
||||||
|
expect(wrapper.text()).toContain('kontakt@bilhej.se')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders complaints email', () => {
|
||||||
|
const wrapper = mount(ContactPage)
|
||||||
|
expect(wrapper.text()).toContain('klagomal@bilhej.se')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links support to mailto', () => {
|
||||||
|
const wrapper = mount(ContactPage)
|
||||||
|
const link = wrapper.find('a[href="mailto:support@bilhej.se"]')
|
||||||
|
expect(link.exists()).toBe(true)
|
||||||
|
expect(link.text()).toBe('support@bilhej.se')
|
||||||
|
expect(link.attributes('aria-label')).toBe('Skicka till support: support@bilhej.se')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
149
frontend/src/__tests__/EditOrderPage.spec.ts
Normal file
149
frontend/src/__tests__/EditOrderPage.spec.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EditOrderPage from '@/pages/EditOrderPage.vue'
|
||||||
|
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||||
|
|
||||||
|
vi.mock('@/api/orders', () => ({
|
||||||
|
fetchOrder: vi.fn(),
|
||||||
|
updateOrder: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { fetchOrder, updateOrder } from '@/api/orders'
|
||||||
|
|
||||||
|
const mockFetchOrder = vi.mocked(fetchOrder)
|
||||||
|
const mockUpdateOrder = vi.mocked(updateOrder)
|
||||||
|
|
||||||
|
const pendingOrder = {
|
||||||
|
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||||
|
plate: 'DEF456',
|
||||||
|
letterText: 'Vill köpa din bil.',
|
||||||
|
status: 'pending_payment',
|
||||||
|
trackingId: null,
|
||||||
|
amountPaid: null,
|
||||||
|
createdAt: '2026-05-14T13:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/orders',
|
||||||
|
name: 'orders',
|
||||||
|
component: { template: '<div>Orders</div>' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/bestallning/:orderId/redigera',
|
||||||
|
name: 'edit-order',
|
||||||
|
component: EditOrderPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/betalning/:orderId',
|
||||||
|
name: 'payment',
|
||||||
|
component: PaymentRedirect,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage(orderId = pendingOrder.id) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push({ name: 'edit-order', params: { orderId } })
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EditOrderPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [router, pinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { wrapper, router }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EditOrderPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockFetchOrder.mockResolvedValue(pendingOrder)
|
||||||
|
mockUpdateOrder.mockResolvedValue(pendingOrder)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading state while fetching', async () => {
|
||||||
|
mockFetchOrder.mockImplementation(() => new Promise(() => {}))
|
||||||
|
const { wrapper } = await mountPage()
|
||||||
|
expect(wrapper.text()).toContain('Laddar beställning...')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads order and pre-fills textarea', async () => {
|
||||||
|
const { wrapper } = await mountPage()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockFetchOrder).toHaveBeenCalledWith(pendingOrder.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const textarea = wrapper.find('textarea')
|
||||||
|
expect(textarea.element.value).toBe('Vill köpa din bil.')
|
||||||
|
expect(wrapper.text()).toContain('DEF456')
|
||||||
|
expect(wrapper.text()).toContain('Redigera brev')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error when order is not pending_payment', async () => {
|
||||||
|
mockFetchOrder.mockResolvedValue({
|
||||||
|
...pendingOrder,
|
||||||
|
status: 'sent',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { wrapper } = await mountPage()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(wrapper.text()).toContain(
|
||||||
|
'Den här beställningen kan inte redigeras',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).toContain('Tillbaka till beställningar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit calls updateOrder and navigates to payment', async () => {
|
||||||
|
const { wrapper, router } = await mountPage()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const textarea = wrapper.find('textarea')
|
||||||
|
await textarea.setValue('Uppdaterat meddelande')
|
||||||
|
const button = wrapper.find('button[type="submit"]')
|
||||||
|
await button.trigger('submit')
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockUpdateOrder).toHaveBeenCalledWith(
|
||||||
|
pendingOrder.id,
|
||||||
|
'Uppdaterat meddelande',
|
||||||
|
)
|
||||||
|
expect(router.currentRoute.value.name).toBe('payment')
|
||||||
|
expect(router.currentRoute.value.params.orderId).toBe(pendingOrder.id)
|
||||||
|
expect(router.currentRoute.value.query.plate).toBe('DEF456')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error message on update failure', async () => {
|
||||||
|
mockUpdateOrder.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
const { wrapper } = await mountPage()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const textarea = wrapper.find('textarea')
|
||||||
|
await textarea.setValue('Uppdaterat meddelande')
|
||||||
|
const button = wrapper.find('button[type="submit"]')
|
||||||
|
await button.trigger('submit')
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(wrapper.text()).toContain('Kunde inte spara ändringarna')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -22,6 +22,11 @@ function createTestRouter() {
|
||||||
name: 'payment',
|
name: 'payment',
|
||||||
component: { template: '<div>Payment</div>' },
|
component: { template: '<div>Payment</div>' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/bestallning/:orderId/redigera',
|
||||||
|
name: 'edit-order',
|
||||||
|
component: { template: '<div>Edit</div>' },
|
||||||
|
},
|
||||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
@ -58,13 +63,39 @@ const mockOrders = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function mockOrdersFetch(orders: unknown) {
|
||||||
|
vi.mocked(globalThis.fetch).mockImplementation((url, init) => {
|
||||||
|
const urlStr = String(url)
|
||||||
|
const method = init?.method ?? 'GET'
|
||||||
|
|
||||||
|
if (urlStr.includes('/payment/swish-info')) {
|
||||||
|
return mockFetchResponse(200, { number: '1234567890', amount: 49 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlStr.includes('/cancel') && method === 'POST') {
|
||||||
|
return mockFetchResponse(200, {
|
||||||
|
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||||
|
plate: 'DEF456',
|
||||||
|
letterText: 'Vill köpa din bil.',
|
||||||
|
status: 'cancelled',
|
||||||
|
trackingId: null,
|
||||||
|
createdAt: '2026-05-14T13:00:00Z',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlStr.includes('/orders')) {
|
||||||
|
return mockFetchResponse(200, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockFetchResponse(404, { message: 'Not found' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('OrdersPage', () => {
|
describe('OrdersPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
globalThis.fetch = vi.fn()
|
globalThis.fetch = vi.fn()
|
||||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
mockOrdersFetch(mockOrders)
|
||||||
mockFetchResponse(200, mockOrders),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders heading and subtitle', async () => {
|
it('renders heading and subtitle', async () => {
|
||||||
|
|
@ -82,6 +113,13 @@ describe('OrdersPage', () => {
|
||||||
expect(wrapper.text()).toContain('Laddar beställningar...')
|
expect(wrapper.text()).toContain('Laddar beställningar...')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows section headings when pending and completed orders exist', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(wrapper.text()).toContain('Obetalda beställningar')
|
||||||
|
expect(wrapper.text()).toContain('Tidigare beställningar')
|
||||||
|
})
|
||||||
|
|
||||||
it('fetches orders from API on mount', async () => {
|
it('fetches orders from API on mount', async () => {
|
||||||
mountPage()
|
mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
@ -110,7 +148,9 @@ describe('OrdersPage', () => {
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
const link = wrapper.find('a[href*="postnord"]')
|
const link = wrapper.find('a[href*="postnord"]')
|
||||||
expect(link.exists()).toBe(true)
|
expect(link.exists()).toBe(true)
|
||||||
|
expect(link.classes()).toContain('orders__tracking-btn')
|
||||||
expect(link.text()).toContain('PN123456789')
|
expect(link.text()).toContain('PN123456789')
|
||||||
|
expect(link.text()).toContain('Spåra brev')
|
||||||
expect(link.attributes('target')).toBe('_blank')
|
expect(link.attributes('target')).toBe('_blank')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -125,9 +165,7 @@ describe('OrdersPage', () => {
|
||||||
createdAt: '2026-05-14T13:00:00Z',
|
createdAt: '2026-05-14T13:00:00Z',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
mockOrdersFetch(ordersWithoutTracking)
|
||||||
mockFetchResponse(200, ordersWithoutTracking),
|
|
||||||
)
|
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
const link = wrapper.find('a[href*="postnord"]')
|
const link = wrapper.find('a[href*="postnord"]')
|
||||||
|
|
@ -137,9 +175,8 @@ describe('OrdersPage', () => {
|
||||||
it('renders order id and message', async () => {
|
it('renders order id and message', async () => {
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
expect(wrapper.text()).toContain('Beställnings-ID')
|
|
||||||
expect(wrapper.text()).toContain('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')
|
expect(wrapper.text()).toContain('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')
|
||||||
expect(wrapper.text()).toContain('Meddelande')
|
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||||
expect(wrapper.text()).toContain('Hej fin bil!')
|
expect(wrapper.text()).toContain('Hej fin bil!')
|
||||||
expect(wrapper.text()).toContain('Vill köpa din bil.')
|
expect(wrapper.text()).toContain('Vill köpa din bil.')
|
||||||
})
|
})
|
||||||
|
|
@ -148,19 +185,24 @@ describe('OrdersPage', () => {
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
expect(wrapper.text()).toContain('2026')
|
expect(wrapper.text()).toContain('2026')
|
||||||
|
expect(wrapper.text()).toContain('Skapad')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows empty state when no orders', async () => {
|
it('shows empty state when no orders', async () => {
|
||||||
vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
|
mockOrdersFetch([])
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
expect(wrapper.text()).toContain('Inga beställningar ännu')
|
expect(wrapper.text()).toContain('Inga beställningar ännu')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows error state on API failure', async () => {
|
it('shows error state on API failure', async () => {
|
||||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
vi.mocked(globalThis.fetch).mockImplementation((url) => {
|
||||||
mockFetchResponse(500, { message: 'Internal server error' }),
|
const urlStr = String(url)
|
||||||
)
|
if (urlStr.includes('/payment/swish-info')) {
|
||||||
|
return mockFetchResponse(200, { number: '1234567890', amount: 49 })
|
||||||
|
}
|
||||||
|
return mockFetchResponse(500, { message: 'Internal server error' })
|
||||||
|
})
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
|
expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
|
||||||
|
|
@ -170,19 +212,35 @@ describe('OrdersPage', () => {
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
const badges = wrapper.findAll('.badge')
|
const badges = wrapper.findAll('.badge')
|
||||||
expect(badges[0].classes()).toContain('badge--success')
|
expect(badges[0].classes()).toContain('badge--warning')
|
||||||
expect(badges[1].classes()).toContain('badge--muted')
|
expect(badges[1].classes()).toContain('badge--success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows order id on pending payment orders', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
|
const pendingCard = wrapper
|
||||||
|
.findAll('.orders__card')
|
||||||
|
.find((card) => card.text().includes('DEF456'))
|
||||||
|
expect(pendingCard?.text()).toContain('Beställnings-ID')
|
||||||
|
expect(pendingCard?.text()).toContain(
|
||||||
|
'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows pay button only for pending payment orders', async () => {
|
it('shows pay button only for pending payment orders', async () => {
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
const payLinks = wrapper.findAll('.orders__pay-btn')
|
const pendingCard = wrapper
|
||||||
expect(payLinks).toHaveLength(1)
|
.findAll('.orders__card')
|
||||||
expect(payLinks[0].text()).toBe('Betala nu')
|
.find((card) => card.text().includes('DEF456'))
|
||||||
|
const payLink = pendingCard?.find('a.orders__pay-btn')
|
||||||
|
expect(payLink?.exists()).toBe(true)
|
||||||
|
expect(payLink?.text()).toBe('Betala 49 kr')
|
||||||
|
|
||||||
const href = payLinks[0].attributes('href')
|
const href = payLink?.attributes('href')
|
||||||
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
|
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
|
||||||
expect(href).toContain('plate=DEF456')
|
expect(href).toContain('plate=DEF456')
|
||||||
})
|
})
|
||||||
|
|
@ -194,7 +252,71 @@ describe('OrdersPage', () => {
|
||||||
const sentCard = wrapper
|
const sentCard = wrapper
|
||||||
.findAll('.orders__card')
|
.findAll('.orders__card')
|
||||||
.find((card) => card.text().includes('ABC123'))
|
.find((card) => card.text().includes('ABC123'))
|
||||||
expect(sentCard?.find('.orders__pay-btn').exists()).toBe(false)
|
expect(sentCard?.find('a.orders__pay-btn').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows edit link for pending payment orders', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
|
const pendingCard = wrapper
|
||||||
|
.findAll('.orders__card')
|
||||||
|
.find((card) => card.text().includes('DEF456'))
|
||||||
|
const editLink = pendingCard?.find('a.orders__edit-btn')
|
||||||
|
expect(editLink?.exists()).toBe(true)
|
||||||
|
expect(editLink?.text()).toBe('Redigera brev')
|
||||||
|
|
||||||
|
const href = editLink?.attributes('href')
|
||||||
|
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
|
||||||
|
expect(href).toContain('redigera')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows cancel button for pending payment orders', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
|
const pendingCard = wrapper
|
||||||
|
.findAll('.orders__card')
|
||||||
|
.find((card) => card.text().includes('DEF456'))
|
||||||
|
const cancelBtn = pendingCard?.find('.orders__cancel-btn')
|
||||||
|
expect(cancelBtn?.exists()).toBe(true)
|
||||||
|
expect(cancelBtn?.text()).toBe('Avbryt beställning')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls cancel API and updates status to Avbruten', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'confirm',
|
||||||
|
vi.fn(() => true),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
|
const pendingCard = wrapper
|
||||||
|
.findAll('.orders__card')
|
||||||
|
.find((card) => card.text().includes('DEF456'))
|
||||||
|
await pendingCard?.find('.orders__cancel-btn').trigger('click')
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/cancel',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
)
|
||||||
|
expect(wrapper.text()).toContain('Avbruten')
|
||||||
|
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show edit or cancel actions for non-pending orders', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
|
const sentCard = wrapper
|
||||||
|
.findAll('.orders__card')
|
||||||
|
.find((card) => card.text().includes('ABC123'))
|
||||||
|
expect(sentCard?.find('.orders__cancel-btn').exists()).toBe(false)
|
||||||
|
expect(sentCard?.text()).not.toContain('Redigera brev')
|
||||||
|
expect(sentCard?.text()).not.toContain('Avbryt beställning')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders processing status correctly', async () => {
|
it('renders processing status correctly', async () => {
|
||||||
|
|
@ -208,13 +330,49 @@ describe('OrdersPage', () => {
|
||||||
createdAt: '2026-05-15T10:00:00Z',
|
createdAt: '2026-05-15T10:00:00Z',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
mockOrdersFetch(ordersWithProcessing)
|
||||||
mockFetchResponse(200, ordersWithProcessing),
|
|
||||||
)
|
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
expect(wrapper.text()).toContain('Hanteras')
|
expect(wrapper.text()).toContain('Hanteras')
|
||||||
const badge = wrapper.find('.badge')
|
const badge = wrapper.find('.badge')
|
||||||
expect(badge.classes()).toContain('badge--primary')
|
expect(badge.classes()).toContain('badge--primary')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows expand toggle for long messages and reveals full text', async () => {
|
||||||
|
const longText =
|
||||||
|
'Hej! Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir så när man kör bil i rusningstid och tempot blir högt.'
|
||||||
|
const ordersWithLongMessage = [
|
||||||
|
{
|
||||||
|
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||||
|
plate: 'ABC123',
|
||||||
|
letterText: longText,
|
||||||
|
status: 'processing',
|
||||||
|
trackingId: null,
|
||||||
|
createdAt: '2026-05-11T12:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockOrdersFetch(ordersWithLongMessage)
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
|
||||||
|
const card = wrapper.find('.orders__card')
|
||||||
|
const preview = card.find('.orders__preview')
|
||||||
|
const toggle = card.find('.orders__preview-toggle')
|
||||||
|
|
||||||
|
expect(toggle.exists()).toBe(true)
|
||||||
|
expect(toggle.text()).toBe('Visa mer')
|
||||||
|
expect(preview.classes()).not.toContain('orders__preview--expanded')
|
||||||
|
|
||||||
|
await toggle.trigger('click')
|
||||||
|
|
||||||
|
expect(preview.classes()).toContain('orders__preview--expanded')
|
||||||
|
expect(toggle.text()).toBe('Visa mindre')
|
||||||
|
expect(card.text()).toContain(longText)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show expand toggle for short messages', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
54
frontend/src/__tests__/PrivacyPolicyPage.spec.ts
Normal file
54
frontend/src/__tests__/PrivacyPolicyPage.spec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import PrivacyPolicyPage from '@/pages/PrivacyPolicyPage.vue'
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/integritetspolicy',
|
||||||
|
name: 'privacy',
|
||||||
|
component: PrivacyPolicyPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/kontakt',
|
||||||
|
name: 'contact',
|
||||||
|
component: { template: '<div>Kontakt</div>' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PrivacyPolicyPage', () => {
|
||||||
|
it('renders title and lead', () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(PrivacyPolicyPage, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Integritetspolicy')
|
||||||
|
expect(wrapper.text()).toContain('personuppgifter')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('describes sender and recipient data handling', () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(PrivacyPolicyPage, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Mottagarens postadress')
|
||||||
|
expect(wrapper.text()).toContain('sparas inte efter utskick')
|
||||||
|
expect(wrapper.text()).toContain('varken vi eller obehöriga')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links to contact email and contact page', () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(PrivacyPolicyPage, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('a[href="mailto:kontakt@bilhej.se"]').exists()).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(wrapper.find('a.policy__link').attributes('href')).toBe('/kontakt')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -32,6 +32,18 @@ describe('Router', () => {
|
||||||
expect(router.currentRoute.value.name).toBe('forgot-password')
|
expect(router.currentRoute.value.name).toBe('forgot-password')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves /integritetspolicy to PrivacyPolicyPage', async () => {
|
||||||
|
await router.push('/integritetspolicy')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('privacy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves /villkor to TermsOfServicePage', async () => {
|
||||||
|
await router.push('/villkor')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('terms')
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves /aterstall-losenord to ResetPasswordPage', async () => {
|
it('resolves /aterstall-losenord to ResetPasswordPage', async () => {
|
||||||
await router.push('/aterstall-losenord?token=abc')
|
await router.push('/aterstall-losenord?token=abc')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|
|
||||||
58
frontend/src/__tests__/TermsOfServicePage.spec.ts
Normal file
58
frontend/src/__tests__/TermsOfServicePage.spec.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import TermsOfServicePage from '@/pages/TermsOfServicePage.vue'
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/villkor',
|
||||||
|
name: 'terms',
|
||||||
|
component: TermsOfServicePage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/integritetspolicy',
|
||||||
|
name: 'privacy',
|
||||||
|
component: { template: '<div>Integritet</div>' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/kontakt',
|
||||||
|
name: 'contact',
|
||||||
|
component: { template: '<div>Kontakt</div>' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TermsOfServicePage', () => {
|
||||||
|
it('renders title and lead', () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(TermsOfServicePage, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Användarvillkor')
|
||||||
|
expect(wrapper.text()).toContain('49 kr')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('describes payment and order rules', () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(TermsOfServicePage, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Swish')
|
||||||
|
expect(wrapper.text()).toContain('Obetalda beställningar kan redigeras')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links to privacy policy and support email', () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
const wrapper = mount(TermsOfServicePage, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('a[href="/integritetspolicy"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('a[href="mailto:support@bilhej.se"]').exists()).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -14,9 +14,26 @@ export function fetchOrders(): Promise<Order[]> {
|
||||||
return request<Order[]>('/orders')
|
return request<Order[]>('/orders')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchOrder(id: string): Promise<Order> {
|
||||||
|
return request<Order>(`/orders/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function createOrder(plate: string, letterText: string): Promise<Order> {
|
export function createOrder(plate: string, letterText: string): Promise<Order> {
|
||||||
return request<Order>('/orders', {
|
return request<Order>('/orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ plate, letterText }),
|
body: JSON.stringify({ plate, letterText }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateOrder(id: string, letterText: string): Promise<Order> {
|
||||||
|
return request<Order>(`/orders/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ letterText }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelOrder(id: string): Promise<Order> {
|
||||||
|
return request<Order>(`/orders/${id}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ import { RouterLink } from 'vue-router'
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="app-footer__inner">
|
<div class="app-footer__inner">
|
||||||
<p class="app-footer__tagline">
|
<p class="app-footer__tagline">
|
||||||
Bilhej hjälper dig att skicka brev till bilägare via
|
Skriv brevet här. Vi postar det till rätt bilägare.
|
||||||
registreringsnummer.
|
|
||||||
</p>
|
</p>
|
||||||
<nav class="app-footer__links">
|
<nav class="app-footer__links">
|
||||||
<RouterLink to="/om" class="app-footer__link">Om oss</RouterLink>
|
<RouterLink to="/om-oss" class="app-footer__link">Om oss</RouterLink>
|
||||||
<RouterLink to="/kontakt" class="app-footer__link">Kontakt</RouterLink>
|
<RouterLink to="/kontakt" class="app-footer__link">Kontakt</RouterLink>
|
||||||
<RouterLink to="/integritetspolicy" class="app-footer__link"
|
<RouterLink to="/integritetspolicy" class="app-footer__link"
|
||||||
>Integritetspolicy</RouterLink
|
>Integritetspolicy</RouterLink
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,226 @@
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const highlights = [
|
||||||
|
{
|
||||||
|
title: 'Skriv brevet här',
|
||||||
|
description:
|
||||||
|
'Välj mall eller skriv fritt. Du ser hela brevet innan du betalar 49 kr via Swish.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Registreringsnummer räcker',
|
||||||
|
description:
|
||||||
|
'Du behöver inte veta vem som äger bilen eller var personen bor. Vi kopplar brevet till rätt mottagare.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vi postar åt dig',
|
||||||
|
description:
|
||||||
|
'Efter betalning hanterar vi utskick manuellt. Du följer status i Mina beställningar och får spårning när brevet skickats.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="about">
|
||||||
<div class="page__card">
|
<section class="about__hero">
|
||||||
<h1>Om Bilhej</h1>
|
<p class="about__eyebrow">Om oss</p>
|
||||||
<p>
|
<h1 class="about__title">Om Bilhej</h1>
|
||||||
Bilhej är en tjänst som låter dig skicka fysiska brev till fordonsägare
|
<p class="about__lead">
|
||||||
via registreringsnummer.
|
Bilhej gör det enkelt att nå en bilägare med ett fysiskt brev. Du
|
||||||
|
skriver meddelandet, vi sköter utskick och post.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<div class="about__prose">
|
||||||
|
<p>
|
||||||
|
Många situationer i trafiken eller på parkeringen är enklare att lösa
|
||||||
|
med ett kort, respektfullt brev än med lapp i vindrutan eller
|
||||||
|
konfrontation på plats. Bilhej är till för det.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Du anger registreringsnummer, skriver vad du vill säga och betalar.
|
||||||
|
Därefter ser vi till att brevet når rätt person med vanlig post. Du
|
||||||
|
behöver inte hantera adress eller jaga upp ägaren själv.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Tjänsten passar till exempel köpförfrågan, tips om något på bilen,
|
||||||
|
vänlig feedback efter trafik eller ett meddelande du formulerar helt
|
||||||
|
själv.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about__section">
|
||||||
|
<h2 class="about__section-title">Så fungerar det</h2>
|
||||||
|
<div class="about__cards">
|
||||||
|
<article
|
||||||
|
v-for="(item, index) in highlights"
|
||||||
|
:key="item.title"
|
||||||
|
class="about__card"
|
||||||
|
>
|
||||||
|
<span class="about__card-step">Steg {{ index + 1 }}</span>
|
||||||
|
<h3>{{ item.title }}</h3>
|
||||||
|
<p>{{ item.description }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about__section about__section--cta">
|
||||||
|
<div class="about__cta-box">
|
||||||
|
<h2 class="about__cta-title">Redo att skriva?</h2>
|
||||||
|
<p class="about__cta-text">
|
||||||
|
Börja med registreringsnumret på startsidan. Det tar bara några
|
||||||
|
minuter att skriva och betala.
|
||||||
|
</p>
|
||||||
|
<RouterLink to="/" class="btn btn--primary about__cta-btn">
|
||||||
|
Till startsidan
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.about {
|
||||||
max-width: 36rem;
|
max-width: 48rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: 0 auto;
|
||||||
padding: 0 var(--space-lg);
|
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.about__hero {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
var(--color-surface) 0%,
|
||||||
|
#f8faff 55%,
|
||||||
|
var(--color-paper) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__title {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__lead {
|
||||||
|
margin: 0 0 var(--space-lg) 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__prose {
|
||||||
|
padding-top: var(--space-lg);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__section {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__section-title {
|
||||||
|
margin: 0 0 var(--space-lg) 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__prose p {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__prose p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__cards {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__card {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--space-xl);
|
padding: var(--space-lg);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.about__card-step {
|
||||||
margin: 0 0 var(--space-md) 0;
|
display: inline-flex;
|
||||||
font-size: 1.5rem;
|
margin-bottom: var(--space-sm);
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
.about__card h3 {
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__card p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.7;
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__cta-box {
|
||||||
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--color-primary-soft) 0%,
|
||||||
|
#eef2ff 100%
|
||||||
|
);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__cta-title {
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__cta-text {
|
||||||
|
margin: 0 0 var(--space-lg) 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about__cta-btn {
|
||||||
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const statusLabels: Record<string, string> = {
|
||||||
sent: 'Skickat',
|
sent: 'Skickat',
|
||||||
delivered: 'Levererat',
|
delivered: 'Levererat',
|
||||||
failed: 'Misslyckad',
|
failed: 'Misslyckad',
|
||||||
|
cancelled: 'Avbruten',
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBadge: Record<string, string> = {
|
const statusBadge: Record<string, string> = {
|
||||||
|
|
@ -36,6 +37,7 @@ const statusBadge: Record<string, string> = {
|
||||||
sent: 'badge--success',
|
sent: 'badge--success',
|
||||||
delivered: 'badge--success',
|
delivered: 'badge--success',
|
||||||
failed: 'badge--danger',
|
failed: 'badge--danger',
|
||||||
|
cancelled: 'badge--muted',
|
||||||
}
|
}
|
||||||
|
|
||||||
const allStatuses = [
|
const allStatuses = [
|
||||||
|
|
@ -45,6 +47,7 @@ const allStatuses = [
|
||||||
'sent',
|
'sent',
|
||||||
'delivered',
|
'delivered',
|
||||||
'failed',
|
'failed',
|
||||||
|
'cancelled',
|
||||||
]
|
]
|
||||||
|
|
||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const canSubmit = computed(
|
||||||
)
|
)
|
||||||
|
|
||||||
const GDPR_FOOTER =
|
const GDPR_FOOTER =
|
||||||
'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se'
|
'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se'
|
||||||
|
|
||||||
function handleTemplateSelect(template: LetterTemplate) {
|
function handleTemplateSelect(template: LetterTemplate) {
|
||||||
letterText.value = template.body
|
letterText.value = template.body
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,149 @@
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
const contactChannels = [
|
||||||
|
{
|
||||||
|
variant: 'support',
|
||||||
|
title: 'Support',
|
||||||
|
description:
|
||||||
|
'Beställningar, betalning via Swish, tekniska problem eller frågor om status och spårning.',
|
||||||
|
email: 'support@bilhej.se',
|
||||||
|
label: 'Skicka till support',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variant: 'general',
|
||||||
|
title: 'Allmän kontakt',
|
||||||
|
description:
|
||||||
|
'Övriga frågor om tjänsten, synpunkter som inte är klagomål, eller om du är osäker på vilken adress du ska använda.',
|
||||||
|
email: 'kontakt@bilhej.se',
|
||||||
|
label: 'Skicka e-post',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variant: 'complaints',
|
||||||
|
title: 'Klagomål',
|
||||||
|
description:
|
||||||
|
'Om något gått fel eller du vill lämna ett klagomål direkt till oss som driver tjänsten.',
|
||||||
|
email: 'klagomal@bilhej.se',
|
||||||
|
label: 'Skicka klagomål',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="contact">
|
||||||
<div class="page__card">
|
<section class="contact__hero">
|
||||||
<h1>Kontakta oss</h1>
|
<p class="contact__eyebrow">Kontakt</p>
|
||||||
<p>
|
<h1 class="contact__title">Kontakta oss</h1>
|
||||||
Har du frågor eller feedback? Hör av dig till oss på
|
<p class="contact__lead">
|
||||||
<a href="mailto:hej@bilhalsning.se">hej@bilhalsning.se</a>.
|
Vi svarar så snart vi kan. Använd support för beställningar och tekniska
|
||||||
|
frågor, kontakt för övrigt, eller klagomål-adressen om något gått fel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
|
<section class="contact__section">
|
||||||
|
<div class="contact__cards">
|
||||||
|
<article
|
||||||
|
v-for="channel in contactChannels"
|
||||||
|
:key="channel.email"
|
||||||
|
class="contact__card"
|
||||||
|
:class="`contact__card--${channel.variant}`"
|
||||||
|
>
|
||||||
|
<h2>{{ channel.title }}</h2>
|
||||||
|
<p>{{ channel.description }}</p>
|
||||||
|
<a
|
||||||
|
class="contact__mailto"
|
||||||
|
:href="`mailto:${channel.email}`"
|
||||||
|
:aria-label="`${channel.label}: ${channel.email}`"
|
||||||
|
>
|
||||||
|
{{ channel.email }}
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="contact__section">
|
||||||
|
<div class="contact__tips">
|
||||||
|
<h2 class="contact__tips-title">Innan du skriver</h2>
|
||||||
|
<ul class="contact__tips-list">
|
||||||
|
<li>
|
||||||
|
Har du en pågående beställning? Kontrollera
|
||||||
|
<strong>Mina beställningar</strong> först. Där ser du status och
|
||||||
|
spårning.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Vid klagomål, skriv gärna med beställnings-ID och
|
||||||
|
registreringsnummer så kan vi hitta ärendet snabbare.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Vi läser all e-post manuellt och återkommer när vi har tittat på
|
||||||
|
ditt ärende.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.contact {
|
||||||
max-width: 36rem;
|
max-width: 48rem;
|
||||||
margin: var(--space-3xl) auto 0;
|
margin: 0 auto;
|
||||||
padding: 0 var(--space-lg);
|
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__card {
|
.contact__hero {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
var(--color-surface) 0%,
|
||||||
|
#f8faff 55%,
|
||||||
|
var(--color-paper) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__title {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__lead {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__section {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__cards {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
|
|
@ -27,13 +151,86 @@
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.contact__card::before {
|
||||||
margin: 0 0 var(--space-md) 0;
|
content: '';
|
||||||
font-size: 1.5rem;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--contact-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
.contact__card--support {
|
||||||
|
--contact-accent: linear-gradient(90deg, #0f766e, #2dd4bf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__card--general {
|
||||||
|
--contact-accent: linear-gradient(90deg, #1d4ed8, #60a5fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__card--complaints {
|
||||||
|
--contact-accent: linear-gradient(90deg, #4338ca, #818cf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__card h2 {
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__card p {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__mailto {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__mailto:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__tips {
|
||||||
|
padding: var(--space-xl);
|
||||||
|
background: var(--color-border-light);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__tips-title {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__tips-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.7;
|
padding-left: 1.25rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__tips-list li + li {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact__tips-list li {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
339
frontend/src/pages/EditOrderPage.vue
Normal file
339
frontend/src/pages/EditOrderPage.vue
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
|
import { fetchOrder, updateOrder, type Order } from '@/api/orders'
|
||||||
|
import { type LetterTemplate } from '@/data/templates'
|
||||||
|
import TemplatePicker from '@/components/TemplatePicker.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const order = ref<Order | null>(null)
|
||||||
|
const letterText = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const showPicker = ref(false)
|
||||||
|
|
||||||
|
const orderId = computed(() => route.params.orderId as string)
|
||||||
|
const plate = computed(() => order.value?.plate ?? '')
|
||||||
|
const canEdit = computed(() => order.value?.status === 'pending_payment')
|
||||||
|
|
||||||
|
const charCount = computed(() => letterText.value.length)
|
||||||
|
const maxChars = 1000
|
||||||
|
const canSubmit = computed(
|
||||||
|
() =>
|
||||||
|
canEdit.value && letterText.value.trim().length > 0 && !submitting.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const GDPR_FOOTER =
|
||||||
|
'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se'
|
||||||
|
|
||||||
|
function handleTemplateSelect(template: LetterTemplate) {
|
||||||
|
letterText.value = template.body
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrder() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetched = await fetchOrder(orderId.value)
|
||||||
|
order.value = fetched
|
||||||
|
if (fetched.status === 'pending_payment') {
|
||||||
|
letterText.value = fetched.letterText
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!canSubmit.value || !order.value) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateOrder(order.value.id, letterText.value)
|
||||||
|
await router.push({
|
||||||
|
name: 'payment',
|
||||||
|
params: { orderId: order.value.id },
|
||||||
|
query: { plate: order.value.plate },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadOrder)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="compose">
|
||||||
|
<p
|
||||||
|
v-if="loading"
|
||||||
|
class="text-muted text-center compose__loading"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Laddar beställning...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else-if="loadError" class="message message--error compose__error">
|
||||||
|
{{ loadError }}
|
||||||
|
<RouterLink to="/orders">Tillbaka till beställningar</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="order && !canEdit"
|
||||||
|
class="message message--error compose__error"
|
||||||
|
>
|
||||||
|
Den här beställningen kan inte redigeras.
|
||||||
|
<RouterLink to="/orders">Tillbaka till beställningar</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="order && canEdit" class="compose__layout">
|
||||||
|
<div class="compose__editor">
|
||||||
|
<h1 class="compose__title">Redigera brev</h1>
|
||||||
|
<p class="compose__plate-badge">
|
||||||
|
<span class="compose__plate-label">Regnr</span>
|
||||||
|
<span class="compose__plate-value">{{ plate }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="compose__form" @submit.prevent="handleSubmit">
|
||||||
|
<div class="field">
|
||||||
|
<div class="compose__label-row">
|
||||||
|
<label for="letter" class="field__label">Ditt meddelande</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="compose__templates-btn"
|
||||||
|
@click="showPicker = true"
|
||||||
|
>
|
||||||
|
Visa mallar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="letter"
|
||||||
|
v-model="letterText"
|
||||||
|
class="field__input compose__textarea"
|
||||||
|
:maxlength="maxChars"
|
||||||
|
rows="12"
|
||||||
|
placeholder="Skriv ditt meddelande här..."
|
||||||
|
></textarea>
|
||||||
|
<p
|
||||||
|
class="field__hint compose__counter"
|
||||||
|
:class="{ 'compose__counter--warn': charCount > 900 }"
|
||||||
|
>
|
||||||
|
{{ charCount }} / {{ maxChars }} tecken
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="message message--error">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn--primary btn--lg compose__submit"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Sparar...' : 'Spara och fortsätt till betalning' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compose__preview">
|
||||||
|
<h2 class="compose__preview-title">Förhandsvisning</h2>
|
||||||
|
<div class="compose__preview-page">
|
||||||
|
<p class="compose__preview-plate-label">
|
||||||
|
Registreringsnummer: {{ plate }}
|
||||||
|
</p>
|
||||||
|
<p class="compose__preview-body">
|
||||||
|
{{ letterText }}
|
||||||
|
</p>
|
||||||
|
<hr class="compose__preview-divider" />
|
||||||
|
<p class="compose__preview-footer-text">{{ GDPR_FOOTER }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TemplatePicker
|
||||||
|
v-if="showPicker"
|
||||||
|
@select="handleTemplateSelect"
|
||||||
|
@close="showPicker = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.compose__loading {
|
||||||
|
margin: var(--space-2xl) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
align-items: start;
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: var(--space-2xl) auto 0;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__title {
|
||||||
|
margin: 0 0 var(--space-lg) 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__plate-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin: 0 0 var(--space-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__plate-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__plate-value {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__label-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__templates-btn {
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border: 1px solid #ddd6fe;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__templates-btn:hover {
|
||||||
|
background: #e9d5ff;
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 10rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__counter {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__counter--warn {
|
||||||
|
color: var(--color-danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__error {
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: var(--space-2xl) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__preview-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__preview {
|
||||||
|
position: sticky;
|
||||||
|
top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__preview-title {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__preview-page {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--color-ink);
|
||||||
|
min-height: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__preview-plate-label {
|
||||||
|
margin: 0 0 var(--space-lg) 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__preview-body {
|
||||||
|
margin: 0 0 var(--space-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__preview-divider {
|
||||||
|
margin: var(--space-lg) 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__preview-footer-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-soft);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.compose__layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose__preview {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +1,46 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { fetchOrders, type Order } from '@/api/orders'
|
import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
|
||||||
|
import { fetchSwishInfo } from '@/api/payment'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const ORDER_AMOUNT_FALLBACK = 49
|
||||||
|
|
||||||
const orders = ref<Order[]>([])
|
const orders = ref<Order[]>([])
|
||||||
|
const orderAmount = ref(ORDER_AMOUNT_FALLBACK)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const actionError = ref('')
|
||||||
|
const cancellingId = ref<string | null>(null)
|
||||||
|
const expandedPreviewIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const PREVIEW_CHAR_LIMIT = 120
|
||||||
|
|
||||||
|
function isLongMessage(text: string): boolean {
|
||||||
|
return text.length > PREVIEW_CHAR_LIMIT
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreviewExpanded(orderId: string): boolean {
|
||||||
|
return expandedPreviewIds.value.has(orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreview(orderId: string) {
|
||||||
|
const next = new Set(expandedPreviewIds.value)
|
||||||
|
if (next.has(orderId)) {
|
||||||
|
next.delete(orderId)
|
||||||
|
} else {
|
||||||
|
next.add(orderId)
|
||||||
|
}
|
||||||
|
expandedPreviewIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingOrders = computed(() =>
|
||||||
|
orders.value.filter((order) => order.status === 'pending_payment'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const completedOrders = computed(() =>
|
||||||
|
orders.value.filter((order) => order.status !== 'pending_payment'),
|
||||||
|
)
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
pending_payment: 'Väntar på betalning',
|
pending_payment: 'Väntar på betalning',
|
||||||
|
|
@ -14,6 +49,7 @@ const statusLabels: Record<string, string> = {
|
||||||
sent: 'Skickat',
|
sent: 'Skickat',
|
||||||
delivered: 'Levererat',
|
delivered: 'Levererat',
|
||||||
failed: 'Misslyckad',
|
failed: 'Misslyckad',
|
||||||
|
cancelled: 'Avbruten',
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBadge: Record<string, string> = {
|
const statusBadge: Record<string, string> = {
|
||||||
|
|
@ -23,6 +59,7 @@ const statusBadge: Record<string, string> = {
|
||||||
sent: 'badge--success',
|
sent: 'badge--success',
|
||||||
delivered: 'badge--success',
|
delivered: 'badge--success',
|
||||||
failed: 'badge--danger',
|
failed: 'badge--danger',
|
||||||
|
cancelled: 'badge--muted',
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
|
|
@ -33,15 +70,47 @@ function formatDate(iso: string): string {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadOrders() {
|
||||||
try {
|
try {
|
||||||
orders.value = await fetchOrders()
|
const [fetchedOrders, swishInfo] = await Promise.all([
|
||||||
|
fetchOrders(),
|
||||||
|
fetchSwishInfo().catch(() => ({
|
||||||
|
number: '',
|
||||||
|
amount: ORDER_AMOUNT_FALLBACK,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
orders.value = fetchedOrders
|
||||||
|
orderAmount.value = swishInfo.amount
|
||||||
} catch {
|
} catch {
|
||||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
async function handleCancel(order: Order) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
'Vill du avbryta beställningen? Den kan inte återställas efteråt.',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionError.value = ''
|
||||||
|
cancellingId.value = order.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await cancelOrder(order.id)
|
||||||
|
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
|
||||||
|
} catch {
|
||||||
|
actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
|
||||||
|
} finally {
|
||||||
|
cancellingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadOrders)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -73,64 +142,174 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="orders__list">
|
<template v-else>
|
||||||
<div v-for="order in orders" :key="order.id" class="orders__card">
|
<div
|
||||||
<div class="orders__card-top">
|
v-if="actionError"
|
||||||
<span class="orders__plate">{{ order.plate }}</span>
|
class="message message--error orders__action-error"
|
||||||
<span
|
role="alert"
|
||||||
class="badge"
|
>
|
||||||
:class="statusBadge[order.status] || 'badge--muted'"
|
{{ actionError }}
|
||||||
>
|
|
||||||
{{ statusLabels[order.status] || order.status }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="orders__card-meta">
|
|
||||||
<span class="orders__meta-label">Beställnings-ID</span>
|
|
||||||
<span class="orders__meta-value orders__order-id">{{
|
|
||||||
order.id
|
|
||||||
}}</span>
|
|
||||||
|
|
||||||
<span class="orders__meta-label">Meddelande</span>
|
|
||||||
<span class="orders__meta-value orders__message">{{
|
|
||||||
order.letterText
|
|
||||||
}}</span>
|
|
||||||
|
|
||||||
<span class="orders__meta-label">Datum</span>
|
|
||||||
<span class="orders__meta-value">{{
|
|
||||||
formatDate(order.createdAt)
|
|
||||||
}}</span>
|
|
||||||
|
|
||||||
<template v-if="order.trackingId">
|
|
||||||
<span class="orders__meta-label">Spårning</span>
|
|
||||||
<a
|
|
||||||
class="orders__tracking"
|
|
||||||
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{{ order.trackingId }}
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="order.status === 'pending_payment'"
|
|
||||||
class="orders__card-actions"
|
|
||||||
>
|
|
||||||
<RouterLink
|
|
||||||
:to="{
|
|
||||||
name: 'payment',
|
|
||||||
params: { orderId: order.id },
|
|
||||||
query: { plate: order.plate },
|
|
||||||
}"
|
|
||||||
class="btn btn--primary orders__pay-btn"
|
|
||||||
>
|
|
||||||
Betala nu
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<section v-if="pendingOrders.length" class="orders__section">
|
||||||
|
<h2 class="orders__section-title">Obetalda beställningar</h2>
|
||||||
|
<div class="orders__list">
|
||||||
|
<div
|
||||||
|
v-for="order in pendingOrders"
|
||||||
|
:key="order.id"
|
||||||
|
class="orders__card orders__card--pending"
|
||||||
|
>
|
||||||
|
<div class="orders__card-content">
|
||||||
|
<div class="orders__card-head">
|
||||||
|
<p class="orders__plate-badge">
|
||||||
|
<span class="orders__plate-label">Regnr</span>
|
||||||
|
<span class="orders__plate-value">{{ order.plate }}</span>
|
||||||
|
</p>
|
||||||
|
<span class="badge badge--warning">
|
||||||
|
{{ statusLabels[order.status] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="orders__preview-box">
|
||||||
|
<p
|
||||||
|
class="orders__preview"
|
||||||
|
:class="{
|
||||||
|
'orders__preview--expanded': isPreviewExpanded(order.id),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ order.letterText }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="isLongMessage(order.letterText)"
|
||||||
|
type="button"
|
||||||
|
class="orders__preview-toggle"
|
||||||
|
@click="togglePreview(order.id)"
|
||||||
|
>
|
||||||
|
{{ isPreviewExpanded(order.id) ? 'Visa mindre' : 'Visa mer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="orders__order-date">
|
||||||
|
Skapad {{ formatDate(order.createdAt) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="orders__order-ref orders__order-ref--highlight">
|
||||||
|
<p class="orders__order-ref-label">Beställnings-ID</p>
|
||||||
|
<p class="orders__order-id">{{ order.id }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="orders__price-row">
|
||||||
|
<span class="orders__price-label">Att betala</span>
|
||||||
|
<span class="orders__price-amount">{{ orderAmount }} kr</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
:to="{
|
||||||
|
name: 'payment',
|
||||||
|
params: { orderId: order.id },
|
||||||
|
query: { plate: order.plate },
|
||||||
|
}"
|
||||||
|
class="btn btn--primary orders__pay-btn"
|
||||||
|
>
|
||||||
|
Betala {{ orderAmount }} kr
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div class="orders__links">
|
||||||
|
<RouterLink
|
||||||
|
:to="{
|
||||||
|
name: 'edit-order',
|
||||||
|
params: { orderId: order.id },
|
||||||
|
}"
|
||||||
|
class="orders__text-link orders__edit-btn"
|
||||||
|
>
|
||||||
|
Redigera brev
|
||||||
|
</RouterLink>
|
||||||
|
<span class="orders__link-sep" aria-hidden="true">·</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="orders__text-link orders__text-link--danger orders__cancel-btn"
|
||||||
|
spellcheck="false"
|
||||||
|
:disabled="cancellingId === order.id"
|
||||||
|
@click="handleCancel(order)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
cancellingId === order.id
|
||||||
|
? 'Avbryter...'
|
||||||
|
: 'Avbryt beställning'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="completedOrders.length" class="orders__section">
|
||||||
|
<h2 v-if="pendingOrders.length" class="orders__section-title">
|
||||||
|
Tidigare beställningar
|
||||||
|
</h2>
|
||||||
|
<div class="orders__list">
|
||||||
|
<div
|
||||||
|
v-for="order in completedOrders"
|
||||||
|
:key="order.id"
|
||||||
|
class="orders__card"
|
||||||
|
>
|
||||||
|
<div class="orders__card-content">
|
||||||
|
<div class="orders__card-head">
|
||||||
|
<p class="orders__plate-badge">
|
||||||
|
<span class="orders__plate-label">Regnr</span>
|
||||||
|
<span class="orders__plate-value">{{ order.plate }}</span>
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="statusBadge[order.status] || 'badge--muted'"
|
||||||
|
>
|
||||||
|
{{ statusLabels[order.status] || order.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="orders__preview-box">
|
||||||
|
<p
|
||||||
|
class="orders__preview"
|
||||||
|
:class="{
|
||||||
|
'orders__preview--expanded': isPreviewExpanded(order.id),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ order.letterText }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="isLongMessage(order.letterText)"
|
||||||
|
type="button"
|
||||||
|
class="orders__preview-toggle"
|
||||||
|
@click="togglePreview(order.id)"
|
||||||
|
>
|
||||||
|
{{ isPreviewExpanded(order.id) ? 'Visa mindre' : 'Visa mer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="orders__order-date">
|
||||||
|
Skapad {{ formatDate(order.createdAt) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
v-if="order.trackingId"
|
||||||
|
class="btn btn--ghost orders__tracking-btn"
|
||||||
|
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Spåra brev · {{ order.trackingId }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="orders__order-ref">
|
||||||
|
<p class="orders__order-ref-label">Beställnings-ID</p>
|
||||||
|
<p class="orders__order-id">{{ order.id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -153,6 +332,23 @@ onMounted(async () => {
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.orders__action-error {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__section + .orders__section {
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__section-title {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
.orders__list {
|
.orders__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -164,76 +360,214 @@ onMounted(async () => {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders__card-top {
|
.orders__card-content {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__card--pending .orders__order-date {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--space-md) var(--space-lg);
|
gap: var(--space-md);
|
||||||
background: var(--color-border-light);
|
margin-bottom: var(--space-md);
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders__plate {
|
.orders__plate-badge {
|
||||||
font-size: 1.125rem;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__plate-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__plate-value {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__preview-box {
|
||||||
|
background: var(--color-border-light);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__preview {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.6;
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__preview--expanded {
|
||||||
|
display: block;
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
line-clamp: unset;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__preview-toggle {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__preview-toggle:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__order-date {
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__tracking-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin: var(--space-md) 0 var(--space-sm);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__tracking-btn:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__order-ref {
|
||||||
|
margin: var(--space-md) 0 0;
|
||||||
|
padding-top: var(--space-md);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__order-ref--highlight {
|
||||||
|
margin: 0 0 var(--space-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-border-light);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__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;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders__card-meta {
|
|
||||||
padding: var(--space-md) var(--space-lg);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: var(--space-sm) var(--space-lg);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orders__meta-label {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-soft);
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orders__meta-value {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.orders__order-id {
|
.orders__order-id {
|
||||||
|
margin: 0;
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-ink);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
|
||||||
|
|
||||||
.orders__message {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders__tracking {
|
.orders__price-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding-bottom: var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__price-label {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-muted);
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders__tracking:hover {
|
.orders__price-amount {
|
||||||
text-decoration: underline;
|
font-size: 1.25rem;
|
||||||
}
|
font-weight: 700;
|
||||||
|
color: var(--color-ink);
|
||||||
.orders__card-actions {
|
|
||||||
padding: var(--space-md) var(--space-lg);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
background: var(--color-border-light);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders__pay-btn {
|
.orders__pay-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__text-link {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__text-link:hover:not(:disabled) {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__text-link--danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__text-link--danger:hover:not(:disabled) {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__text-link:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders__link-sep {
|
||||||
|
color: var(--color-soft);
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders__empty {
|
.orders__empty {
|
||||||
|
|
|
||||||
239
frontend/src/pages/PrivacyPolicyPage.vue
Normal file
239
frontend/src/pages/PrivacyPolicyPage.vue
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
id: 'ansvarig',
|
||||||
|
title: 'Personuppgiftsansvarig',
|
||||||
|
paragraphs: [
|
||||||
|
'Bilhej (bilhej.se) är personuppgiftsansvarig för den behandling som sker när du använder tjänsten som avsändare.',
|
||||||
|
'Frågor om integritet: kontakt@bilhej.se. Klagomål som rör tjänsten: klagomal@bilhej.se.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vilka-uppgifter',
|
||||||
|
title: 'Vilka uppgifter behandlar vi?',
|
||||||
|
paragraphs: [
|
||||||
|
'Kontouppgifter: e-postadress och lösenord. Lösenordet lagras så att varken vi eller obehöriga kan läsa det.',
|
||||||
|
'Beställningar: registreringsnummer, brevtext, status, betalningsbelopp och eventuellt spårningsnummer kopplat till utskick.',
|
||||||
|
'Fordonsuppgifter: märke och modell för att du ska kunna verifiera nummerplåten. Vi lagrar inte mottagarens namn eller adress i tjänsten.',
|
||||||
|
'Mottagarens postadress används enbart för att posta brevet och sparas inte efter utskick.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'varfor',
|
||||||
|
title: 'Varför behandlar vi uppgifterna?',
|
||||||
|
paragraphs: [
|
||||||
|
'För att tillhandahålla tjänsten: konto, beställning, betalning och utskick av brev.',
|
||||||
|
'För att uppfylla rättsliga skyldigheter, till exempel bokföring av betalningar där så krävs.',
|
||||||
|
'För att skicka nödvändiga meddelanden till dig, till exempel återställning av lösenord.',
|
||||||
|
'Mottagaren informeras i brevets fot om att brevet skickats via Bilhej och hur hen kan kontakta oss.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delning',
|
||||||
|
title: 'Vem delar vi uppgifter med?',
|
||||||
|
paragraphs: [
|
||||||
|
'Leverantörer som hjälper oss att skicka e-post, ta emot supportmail och driva webbplatsen.',
|
||||||
|
'Behörig myndighet och postoperatör i den utsträckning som krävs för att posta brevet till rätt mottagare.',
|
||||||
|
'Offentliga fordonsregister när du verifierar registreringsnummer (endast fordonsdata, inte ägaruppgifter).',
|
||||||
|
'Vi säljer inte personuppgifter och visar inte mottagarens identitet eller adress för dig som avsändare.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lagring',
|
||||||
|
title: 'Hur länge sparar vi uppgifterna?',
|
||||||
|
paragraphs: [
|
||||||
|
'Kontouppgifter sparas tills du ber oss radera kontot.',
|
||||||
|
'Beställningshistorik (nummerplåt, brevtext, status) sparas så att du kan se Mina beställningar och så att vi kan hantera support.',
|
||||||
|
'Mottagarens adress sparas inte efter utskick.',
|
||||||
|
'Lösenordsåterställning gäller under begränsad tid och upphör efter användning.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rattigheter',
|
||||||
|
title: 'Dina rättigheter',
|
||||||
|
paragraphs: [
|
||||||
|
'Du kan begära tillgång till, rättelse eller radering av dina uppgifter, begränsa behandling eller invända mot viss behandling.',
|
||||||
|
'Du kan begära radering av ditt konto genom att kontakta oss.',
|
||||||
|
'Du har rätt att lämna klagomål till Integritetsskyddsmyndigheten (IMY), imy.se.',
|
||||||
|
'Kontakta kontakt@bilhej.se för att utöva dina rättigheter. Vi svarar inom en månad om inte något annat anges.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mottagare',
|
||||||
|
title: 'Om du fått ett brev via Bilhej',
|
||||||
|
paragraphs: [
|
||||||
|
'Din adress användes en gång för att posta brevet och ska ha raderats hos oss efter utskick.',
|
||||||
|
'För frågor eller invändning mot att få brev via tjänsten: kontakt@bilhej.se. Ange registreringsnummer om du kan.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'andringar',
|
||||||
|
title: 'Ändringar',
|
||||||
|
paragraphs: [
|
||||||
|
'Vi kan uppdatera denna policy när tjänsten utvecklas. Datum för senaste version anges nedan.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="policy">
|
||||||
|
<section class="policy__hero">
|
||||||
|
<p class="policy__eyebrow">Integritet</p>
|
||||||
|
<h1 class="policy__title">Integritetspolicy</h1>
|
||||||
|
<p class="policy__lead">
|
||||||
|
Här beskriver vi hur Bilhej behandlar personuppgifter när du skickar brev
|
||||||
|
via tjänsten, och vilka rättigheter du har.
|
||||||
|
</p>
|
||||||
|
<p class="policy__updated">Senast uppdaterad: 22 maj 2026</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.id"
|
||||||
|
:id="section.id"
|
||||||
|
class="policy__section"
|
||||||
|
>
|
||||||
|
<h2 class="policy__section-title">{{ section.title }}</h2>
|
||||||
|
<div class="policy__prose">
|
||||||
|
<p v-for="(paragraph, index) in section.paragraphs" :key="index">
|
||||||
|
{{ paragraph }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="policy__section policy__section--cta">
|
||||||
|
<div class="policy__cta-box">
|
||||||
|
<h2 class="policy__cta-title">Frågor om integritet?</h2>
|
||||||
|
<p class="policy__cta-text">
|
||||||
|
Hör av dig via
|
||||||
|
<a class="policy__mailto" href="mailto:kontakt@bilhej.se"
|
||||||
|
>kontakt@bilhej.se</a
|
||||||
|
>
|
||||||
|
eller vår
|
||||||
|
<RouterLink to="/kontakt" class="policy__link">kontaktsida</RouterLink>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.policy {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__hero {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
var(--color-surface) 0%,
|
||||||
|
#f8faff 55%,
|
||||||
|
var(--color-paper) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__title {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__lead {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__updated {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__section {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
scroll-margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__section-title {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__prose p {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__prose p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__cta-box {
|
||||||
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--color-primary-soft) 0%,
|
||||||
|
#eef2ff 100%
|
||||||
|
);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__cta-title {
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__cta-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__mailto,
|
||||||
|
.policy__link {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy__mailto:hover,
|
||||||
|
.policy__link:hover {
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
258
frontend/src/pages/TermsOfServicePage.vue
Normal file
258
frontend/src/pages/TermsOfServicePage.vue
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
id: 'tjansten',
|
||||||
|
title: 'Tjänsten',
|
||||||
|
paragraphs: [
|
||||||
|
'Bilhej (bilhej.se) är en tjänst där du som avsändare kan beställa utskick av ett fysiskt brev till en fordonsägare via registreringsnummer. Vi är mellanhand för att posta brevet. Vi är inte part i innehållet mellan dig och mottagaren.',
|
||||||
|
'Genom att skapa konto, beställa eller betala accepterar du dessa villkor.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'konto',
|
||||||
|
title: 'Konto',
|
||||||
|
paragraphs: [
|
||||||
|
'Du ansvarar för att uppgifterna i ditt konto stämmer och att ditt lösenord hålls hemligt.',
|
||||||
|
'Du måste vara minst 18 år, eller ha målsmans tillstånd, för att använda tjänsten.',
|
||||||
|
'Vi får stänga av eller avsluta konton som bryter mot villkoren eller missbrukar tjänsten.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bestallning',
|
||||||
|
title: 'Beställning och betalning',
|
||||||
|
paragraphs: [
|
||||||
|
'Priset för ett brev framgår i tjänsten (för närvarande 49 kr). Betalning sker via Swish enligt instruktionerna i beställningsflödet.',
|
||||||
|
'Du ser hela brevet innan du betalar. Kontrollera registreringsnummer och text noga.',
|
||||||
|
'Obetalda beställningar kan redigeras eller avbrytas i Mina beställningar. Efter betalning behandlas beställningen för utskick.',
|
||||||
|
'Vi kan avvisa eller stoppa beställningar med olagligt, kränkande, hotfullt eller uppenbart missbrukande innehåll. Betald avgift återbetalas i så fall om utskick inte hunnit ske.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'innehåll',
|
||||||
|
title: 'Innehåll i brevet',
|
||||||
|
paragraphs: [
|
||||||
|
'Du ansvarar själv för texten du skickar. Brevet ska vara respektfullt och följa svensk lag.',
|
||||||
|
'Du får inte använda tjänsten för trakasserier, hot, olaglig reklam, spridning av personuppgifter om tredje man, eller annat otillbörligt innehåll.',
|
||||||
|
'Om du anger kontaktuppgifter i brevet kan mottagaren svara dig direkt. Annars är avsändaren anonym gentemot mottagaren.',
|
||||||
|
'Du garanterar att du har rätt att skicka innehållet och ger oss rätt att skriva ut och posta brevet en gång för att fullgöra beställningen.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'utskick',
|
||||||
|
title: 'Utskick och leverans',
|
||||||
|
paragraphs: [
|
||||||
|
'Efter betalning hanterar vi utskick av brevet. Det sker normalt inom några vardagar, men exakt tid kan variera.',
|
||||||
|
'Vi garanterar inte att mottagaren läser eller svarar på brevet.',
|
||||||
|
'När spårning finns tillgänglig visas den i Mina beställningar. Postens leveranstider ligger utanför vår kontroll.',
|
||||||
|
'Om registreringsnumret är felaktigt eller mottagaren inte kan nås kan utskick misslyckas. Kontakta support@bilhej.se så hjälper vi dig.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reklamation',
|
||||||
|
title: 'Reklamation och återbetalning',
|
||||||
|
paragraphs: [
|
||||||
|
'Har något blivit fel? Kontakta support@bilhej.se så snart som möjligt och ange beställnings-ID.',
|
||||||
|
'Om brevet inte skickats kan vi i normalfallet återbetala eller korrigera beställningen.',
|
||||||
|
'Efter att brevet postats kan återbetalning normalt inte ske, eftersom tjänsten då är utförd.',
|
||||||
|
'Som konsument har du enligt lag rätt att reklamera fel i tjänsten inom tre år. Kontakta oss först så löser vi det i god tro.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ansvar',
|
||||||
|
title: 'Ansvar',
|
||||||
|
paragraphs: [
|
||||||
|
'Tjänsten tillhandahålls i befintligt skick. Vi ansvarar inte för indirekta skador, utebliven vinst eller följder av innehållet i brev du skrivit.',
|
||||||
|
'Vårt ansvar gentemot dig som konsument begränsas inte i strid med tvingande lag.',
|
||||||
|
'Du håller Bilhej skadeslöst från krav från tredje part som beror på ditt innehåll eller ditt brott mot dessa villkor, i den utsträckning lagen tillåter.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integritet',
|
||||||
|
title: 'Personuppgifter',
|
||||||
|
paragraphs: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'andringar',
|
||||||
|
title: 'Ändringar av villkoren',
|
||||||
|
paragraphs: [
|
||||||
|
'Vi kan uppdatera villkoren när tjänsten ändras. Fortsatt användning efter att ändringar publicerats innebär att du accepterar de uppdaterade villkoren.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tvist',
|
||||||
|
title: 'Tillämplig lag och kontakt',
|
||||||
|
paragraphs: [
|
||||||
|
'Svensk lag gäller för dessa villkor. Tvister ska i första hand lösas i samförstånd. Konsument kan vända sig till Allmänna reklamationsnämnden (arn.se).',
|
||||||
|
'Frågor om tjänsten: support@bilhej.se. Allmän kontakt: kontakt@bilhej.se. Klagomål: klagomal@bilhej.se.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="terms">
|
||||||
|
<section class="terms__hero">
|
||||||
|
<p class="terms__eyebrow">Villkor</p>
|
||||||
|
<h1 class="terms__title">Användarvillkor</h1>
|
||||||
|
<p class="terms__lead">
|
||||||
|
Villkor för att använda Bilhej när du beställer utskick av brev till
|
||||||
|
fordonsägare.
|
||||||
|
</p>
|
||||||
|
<p class="terms__updated">Senast uppdaterad: 22 maj 2026</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.id"
|
||||||
|
:id="section.id"
|
||||||
|
class="terms__section"
|
||||||
|
>
|
||||||
|
<h2 class="terms__section-title">{{ section.title }}</h2>
|
||||||
|
<div class="terms__prose">
|
||||||
|
<p v-if="section.id === 'integritet'">
|
||||||
|
Vi behandlar personuppgifter enligt vår
|
||||||
|
<RouterLink to="/integritetspolicy" class="terms__link"
|
||||||
|
>integritetspolicy</RouterLink
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p v-for="(paragraph, index) in section.paragraphs" :key="index">
|
||||||
|
{{ paragraph }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="terms__section terms__section--cta">
|
||||||
|
<div class="terms__cta-box">
|
||||||
|
<h2 class="terms__cta-title">Frågor om villkoren?</h2>
|
||||||
|
<p class="terms__cta-text">
|
||||||
|
Hör av dig via
|
||||||
|
<a class="terms__mailto" href="mailto:support@bilhej.se"
|
||||||
|
>support@bilhej.se</a
|
||||||
|
>
|
||||||
|
eller vår
|
||||||
|
<RouterLink to="/kontakt" class="terms__link">kontaktsida</RouterLink>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.terms {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__hero {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
var(--color-surface) 0%,
|
||||||
|
#f8faff 55%,
|
||||||
|
var(--color-paper) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__title {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__lead {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__updated {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__section {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
scroll-margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__section-title {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__prose p {
|
||||||
|
margin: 0 0 var(--space-md) 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__prose p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__cta-box {
|
||||||
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--color-primary-soft) 0%,
|
||||||
|
#eef2ff 100%
|
||||||
|
);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__cta-title {
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__cta-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__mailto,
|
||||||
|
.terms__link {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms__mailto:hover,
|
||||||
|
.terms__link:hover {
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -3,6 +3,8 @@ import HomePage from '@/pages/HomePage.vue'
|
||||||
import ComposePage from '@/pages/ComposePage.vue'
|
import ComposePage from '@/pages/ComposePage.vue'
|
||||||
import AboutPage from '@/pages/AboutPage.vue'
|
import AboutPage from '@/pages/AboutPage.vue'
|
||||||
import ContactPage from '@/pages/ContactPage.vue'
|
import ContactPage from '@/pages/ContactPage.vue'
|
||||||
|
import PrivacyPolicyPage from '@/pages/PrivacyPolicyPage.vue'
|
||||||
|
import TermsOfServicePage from '@/pages/TermsOfServicePage.vue'
|
||||||
import RegisterPage from '@/pages/RegisterPage.vue'
|
import RegisterPage from '@/pages/RegisterPage.vue'
|
||||||
import LoginPage from '@/pages/LoginPage.vue'
|
import LoginPage from '@/pages/LoginPage.vue'
|
||||||
import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
|
import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
|
||||||
|
|
@ -11,6 +13,7 @@ import ChangePasswordPage from '@/pages/ChangePasswordPage.vue'
|
||||||
import ChangeEmailPage from '@/pages/ChangeEmailPage.vue'
|
import ChangeEmailPage from '@/pages/ChangeEmailPage.vue'
|
||||||
import ConfirmEmailChangePage from '@/pages/ConfirmEmailChangePage.vue'
|
import ConfirmEmailChangePage from '@/pages/ConfirmEmailChangePage.vue'
|
||||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
import OrdersPage from '@/pages/OrdersPage.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 { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
@ -36,6 +39,12 @@ const router = createRouter({
|
||||||
component: OrdersPage,
|
component: OrdersPage,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/bestallning/:orderId/redigera',
|
||||||
|
name: 'edit-order',
|
||||||
|
component: EditOrderPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/andra-losenord',
|
path: '/andra-losenord',
|
||||||
name: 'change-password',
|
name: 'change-password',
|
||||||
|
|
@ -90,15 +99,29 @@ const router = createRouter({
|
||||||
component: ConfirmEmailChangePage,
|
component: ConfirmEmailChangePage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/om',
|
path: '/om-oss',
|
||||||
name: 'about',
|
name: 'about',
|
||||||
component: AboutPage,
|
component: AboutPage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/om',
|
||||||
|
redirect: '/om-oss',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/kontakt',
|
path: '/kontakt',
|
||||||
name: 'contact',
|
name: 'contact',
|
||||||
component: ContactPage,
|
component: ContactPage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/integritetspolicy',
|
||||||
|
name: 'privacy',
|
||||||
|
component: PrivacyPolicyPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/villkor',
|
||||||
|
name: 'terms',
|
||||||
|
component: TermsOfServicePage,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue