feat: add order history page with API endpoint and seeded test data
- Create OrderController with GET /api/orders endpoint (authenticated) - Add OrderResponse DTO (id, plate, template, status, trackingId, createdAt) - Seed 3 test orders for test@bilhalsning.se via V6 migration (sent, pending_payment, delivered) - Create OrderControllerTest with 4 tests (auth, empty list, full fields, user not found) - Create frontend api/orders.ts with typed fetchOrders() client - Build out OrdersPage.vue with card list: plate, template, status badge, tracking link - Add 12 Vitest tests for OrdersPage (loading, data, badges, links, empty, error) - Add 5 Playwright E2E tests (auth guard, seeded data, badges, tracking, templates)
This commit is contained in:
parent
a74bb89824
commit
32b315654e
8 changed files with 648 additions and 1 deletions
|
|
@ -0,0 +1,49 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders")
|
||||
@RequiredArgsConstructor
|
||||
public class OrderController {
|
||||
|
||||
private final OrderService orderService;
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<OrderResponse>> list(@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
List<OrderResponse> orders = orderService.getOrdersByUserId(user.getId()).stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(orders);
|
||||
}
|
||||
|
||||
private OrderResponse toResponse(Order order) {
|
||||
return new OrderResponse(
|
||||
order.getId(),
|
||||
order.getPlate(),
|
||||
order.getTemplate(),
|
||||
order.getStatus().getValue(),
|
||||
order.getTrackingId(),
|
||||
order.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
13
backend/src/main/java/se/bilhalsning/dto/OrderResponse.java
Normal file
13
backend/src/main/java/se/bilhalsning/dto/OrderResponse.java
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record OrderResponse(
|
||||
UUID id,
|
||||
String plate,
|
||||
String template,
|
||||
String status,
|
||||
String trackingId,
|
||||
Instant createdAt
|
||||
) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- Seed orders for test user (test@bilhalsning.se, id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
|
||||
INSERT INTO orders (id, user_id, plate, template, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
|
||||
VALUES
|
||||
('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Komplimang', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'),
|
||||
('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DEF456', 'Jag vill köpa din bil', 'Hej! Jag är intresserad av att köpa din bil. Kontakta mig gärna på test@example.com så kan vi diskutera ett pris.', 'pending_payment', NULL, NULL, TIMESTAMP '2026-05-14 13:00:00', TIMESTAMP '2026-05-14 13:00:00'),
|
||||
('c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'GHI789', 'Tips / servicebehov', 'Hej! Jag noterade att ditt bakre högra hjul har lite för lågt lufttryck. Tänkte det kan vara bra att veta!', 'delivered', 49.00, 'PN987654321', TIMESTAMP '2026-05-07 10:00:00', TIMESTAMP '2026-05-12 10:00:00');
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class OrderControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private OrderService orderService;
|
||||
|
||||
@MockitoBean
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhalsning.se")
|
||||
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhalsning.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
|
||||
|
||||
OrderResponse order1 = new OrderResponse(
|
||||
UUID.randomUUID(), "ABC123", "Komplimang", "sent", "PN123456789", Instant.now());
|
||||
OrderResponse order2 = new OrderResponse(
|
||||
UUID.randomUUID(), "DEF456", null, "pending_payment", null, Instant.now());
|
||||
|
||||
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhalsning.se")
|
||||
void shouldReturnOrderWithAllFields() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhalsning.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
|
||||
order.setUserId(userId);
|
||||
order.setPlate("ABC123");
|
||||
order.setTemplate("Komplimang");
|
||||
order.setLetterText("Test letter");
|
||||
order.setStatus(se.bilhalsning.entity.OrderStatus.SENT);
|
||||
order.setTrackingId("PN123456789");
|
||||
|
||||
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of(order));
|
||||
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
|
||||
.andExpect(jsonPath("$[0].plate").value("ABC123"))
|
||||
.andExpect(jsonPath("$[0].template").value("Komplimang"))
|
||||
.andExpect(jsonPath("$[0].status").value("sent"))
|
||||
.andExpect(jsonPath("$[0].trackingId").value("PN123456789"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "unknown@example.com")
|
||||
void shouldReturn401WhenUserNotFound() throws Exception {
|
||||
when(userService.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
88
frontend/e2e/order-history.spec.ts
Normal file
88
frontend/e2e/order-history.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Order history', () => {
|
||||
test('redirects unauthenticated user to login', async ({ page }) => {
|
||||
await page.goto('/orders')
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('can navigate from home to orders via header link', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await header.getByRole('link', { name: 'Mina beställningar' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/orders')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Mina beställningar' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('displays page heading and seeded orders', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Mina beställningar' })).toBeVisible()
|
||||
await expect(page.getByText('ABC123')).toBeVisible()
|
||||
await expect(page.getByText('DEF456')).toBeVisible()
|
||||
await expect(page.getByText('GHI789')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows correct status badges', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
await expect(page.getByText('Skickat')).toBeVisible()
|
||||
await expect(page.getByText('Väntar på betalning')).toBeVisible()
|
||||
await expect(page.getByText('Levererat')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows tracking links for orders with tracking ID', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
const trackingLink1 = page.getByRole('link', { name: 'PN123456789' })
|
||||
await expect(trackingLink1).toBeVisible()
|
||||
await expect(trackingLink1).toHaveAttribute('href', /postnord/)
|
||||
await expect(trackingLink1).toHaveAttribute('target', '_blank')
|
||||
|
||||
const trackingLink2 = page.getByRole('link', { name: 'PN987654321' })
|
||||
await expect(trackingLink2).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows template names', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await page.waitForURL('/')
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
await expect(page.getByText('Komplimang')).toBeVisible()
|
||||
await expect(page.getByText('Jag vill köpa din bil')).toBeVisible()
|
||||
await expect(page.getByText('Tips / servicebehov')).toBeVisible()
|
||||
})
|
||||
})
|
||||
166
frontend/src/__tests__/OrdersPage.spec.ts
Normal file
166
frontend/src/__tests__/OrdersPage.spec.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/orders', name: 'orders', component: OrdersPage },
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
const router = createTestRouter()
|
||||
const pinia = createPinia()
|
||||
router.push('/orders')
|
||||
return {
|
||||
router,
|
||||
wrapper: mount(OrdersPage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const mockOrders = [
|
||||
{
|
||||
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
plate: 'ABC123',
|
||||
template: 'Komplimang',
|
||||
status: 'sent',
|
||||
trackingId: 'PN123456789',
|
||||
createdAt: '2026-05-11T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
plate: 'DEF456',
|
||||
template: null,
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('OrdersPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, mockOrders),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders heading and subtitle', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Mina beställningar')
|
||||
expect(wrapper.text()).toContain('Här kan du se dina tidigare beställningar')
|
||||
})
|
||||
|
||||
it('shows loading state initially', async () => {
|
||||
globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {}))
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.text()).toContain('Laddar beställningar...')
|
||||
})
|
||||
|
||||
it('fetches orders from API on mount', async () => {
|
||||
mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/orders',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders order cards with plate numbers', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('ABC123')
|
||||
expect(wrapper.text()).toContain('DEF456')
|
||||
})
|
||||
|
||||
it('renders template name or fallback for null', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Komplimang')
|
||||
expect(wrapper.text()).toContain('Fritt meddelande')
|
||||
})
|
||||
|
||||
it('renders Swedish status labels', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Skickat')
|
||||
expect(wrapper.text()).toContain('Väntar på betalning')
|
||||
})
|
||||
|
||||
it('renders tracking link when trackingId exists', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const link = wrapper.find('a[href*="postnord"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.text()).toContain('PN123456789')
|
||||
expect(link.attributes('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('does not render tracking link when trackingId is null', async () => {
|
||||
const ordersWithoutTracking = [
|
||||
{
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
plate: 'DEF456',
|
||||
template: null,
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
},
|
||||
]
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, ordersWithoutTracking),
|
||||
)
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const link = wrapper.find('a[href*="postnord"]')
|
||||
expect(link.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders formatted date', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('2026')
|
||||
})
|
||||
|
||||
it('shows empty state when no orders', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Du har inga beställningar ännu')
|
||||
})
|
||||
|
||||
it('shows error state on API failure', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(500, { message: 'Internal server error' }),
|
||||
)
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
|
||||
})
|
||||
|
||||
it('applies correct badge class for status', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const badges = wrapper.findAll('.orders__badge')
|
||||
expect(badges[0].classes()).toContain('badge--green')
|
||||
expect(badges[1].classes()).toContain('badge--gray')
|
||||
})
|
||||
})
|
||||
14
frontend/src/api/orders.ts
Normal file
14
frontend/src/api/orders.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { request } from './client'
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
plate: string
|
||||
template: string | null
|
||||
status: string
|
||||
trackingId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function fetchOrders(): Promise<Order[]> {
|
||||
return request<Order[]>('/orders')
|
||||
}
|
||||
|
|
@ -1,10 +1,98 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchOrders, type Order } from '@/api/orders'
|
||||
|
||||
const orders = ref<Order[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending_payment: 'Väntar på betalning',
|
||||
paid: 'Betalad',
|
||||
lookup_started: 'Hanteras',
|
||||
sent: 'Skickat',
|
||||
delivered: 'Levererat',
|
||||
failed: 'Misslyckad',
|
||||
}
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
pending_payment: 'badge--gray',
|
||||
paid: 'badge--blue',
|
||||
lookup_started: 'badge--blue',
|
||||
sent: 'badge--green',
|
||||
delivered: 'badge--green',
|
||||
failed: 'badge--red',
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
orders.value = await fetchOrders()
|
||||
} catch {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="orders">
|
||||
<h1 class="orders__title">Mina beställningar</h1>
|
||||
<p class="orders__subtitle">Här kan du se dina tidigare beställningar.</p>
|
||||
|
||||
<p v-if="loading" class="orders__loading">Laddar beställningar...</p>
|
||||
|
||||
<p v-else-if="error" class="orders__error">{{ error }}</p>
|
||||
|
||||
<p v-else-if="orders.length === 0" class="orders__empty">
|
||||
Du har inga beställningar ännu.
|
||||
</p>
|
||||
|
||||
<div v-else class="orders__list">
|
||||
<div v-for="order in orders" :key="order.id" class="orders__card">
|
||||
<div class="orders__card-header">
|
||||
<span class="orders__plate">{{ order.plate }}</span>
|
||||
<span
|
||||
class="orders__badge"
|
||||
:class="statusClasses[order.status] || 'badge--gray'"
|
||||
>
|
||||
{{ statusLabels[order.status] || order.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="orders__card-body">
|
||||
<div class="orders__detail">
|
||||
<span class="orders__label">Mall</span>
|
||||
<span class="orders__value">{{ order.template || 'Fritt meddelande' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="orders__detail">
|
||||
<span class="orders__label">Datum</span>
|
||||
<span class="orders__value">{{ formatDate(order.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="order.trackingId" class="orders__detail">
|
||||
<span class="orders__label">Spårning</span>
|
||||
<a
|
||||
class="orders__tracking-link"
|
||||
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ order.trackingId }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -22,8 +110,127 @@
|
|||
}
|
||||
|
||||
.orders__subtitle {
|
||||
margin: 0;
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #718096;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.orders__loading,
|
||||
.orders__error,
|
||||
.orders__empty {
|
||||
margin: 2rem 0;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.orders__loading {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.orders__error {
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.orders__empty {
|
||||
background: #f7fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.orders__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.orders__card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orders__card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #f7fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.orders__plate {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.orders__badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge--gray {
|
||||
background: #edf2f7;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.badge--blue {
|
||||
background: #ebf8ff;
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
.badge--green {
|
||||
background: #f0fff4;
|
||||
color: #276749;
|
||||
}
|
||||
|
||||
.badge--red {
|
||||
background: #fff5f5;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.orders__card-body {
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.orders__detail {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.orders__label {
|
||||
min-width: 5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #a0aec0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.orders__value {
|
||||
font-size: 0.875rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.orders__tracking-link {
|
||||
font-size: 0.875rem;
|
||||
color: #4299e1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.orders__tracking-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue