Compare commits

..

No commits in common. "0f613b21a6cdc870b9153333830fb1858d40610b" and "e8530b8d95530e249021867e914099fb8aa1ced7" have entirely different histories.

20 changed files with 87 additions and 358 deletions

View file

@ -23,6 +23,3 @@ STRIPE_WEBHOOK_SECRET=whsec_...
# Price ID from Stripe Dashboard: https://dashboard.stripe.com/test/products
STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ----------
SWISH_NUMBER=0701234567

View file

@ -36,7 +36,6 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/payment/swish-info").permitAll()
.requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())

View file

@ -1,11 +1,7 @@
package se.bilhalsning.controller;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@ -14,34 +10,21 @@ import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.OrderService;
import java.util.UUID;
@RestController
@RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentController {
private final OrderService orderService;
private final String swishNumber;
private final int letterPrice;
public PaymentController(
OrderService orderService,
@Value("${app.payment.swish-number}") String swishNumber,
@Value("${app.payment.letter-price}") int letterPrice) {
this.orderService = orderService;
this.swishNumber = swishNumber;
this.letterPrice = letterPrice;
}
@PostMapping("/{orderId}/pay")
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
Order order = orderService.confirmPayment(orderId);
Order order = orderService.markAsPaid(orderId);
return ResponseEntity.ok(toResponse(order));
}
@GetMapping("/swish-info")
public ResponseEntity<Map<String, Object>> swishInfo() {
return ResponseEntity.ok(Map.of("number", swishNumber, "amount", letterPrice));
}
private OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),

View file

@ -6,7 +6,7 @@ import jakarta.validation.constraints.Pattern;
public record UpdateStatusRequest(
@NotBlank(message = "Status krävs")
@Pattern(
regexp = "pending_payment|paid|processing|sent|delivered|failed",
regexp = "pending_payment|paid|lookup_started|sent|delivered|failed",
message = "Ogiltig status"
)
String status

View file

@ -3,7 +3,7 @@ package se.bilhalsning.entity;
public enum OrderStatus {
PENDING_PAYMENT("pending_payment"),
PAID("paid"),
PROCESSING("processing"),
LOOKUP_STARTED("lookup_started"),
SENT("sent"),
DELIVERED("delivered"),
FAILED("failed");

View file

@ -7,6 +7,7 @@ import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
@ -55,11 +56,12 @@ public class OrderService {
return orderRepository.save(order);
}
public Order confirmPayment(UUID orderId) {
public Order markAsPaid(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.setStatus(OrderStatus.PROCESSING);
order.setStatus(OrderStatus.PAID);
order.setAmountPaid(new BigDecimal("49.00"));
return orderRepository.save(order);
}
}

View file

@ -13,8 +13,5 @@ spring:
database-platform: org.hibernate.dialect.PostgreSQLDialect
app:
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt:
secret: ${JWT_SECRET}

View file

@ -25,8 +25,5 @@ spring:
locations: classpath:db/migration
app:
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt:
secret: ${JWT_SECRET:dev-secret-change-in-production}

View file

@ -10,7 +10,7 @@ CREATE TABLE orders (
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_orders PRIMARY KEY (id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'processing', 'sent', 'delivered', 'failed'))
CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'lookup_started', 'sent', 'delivered', 'failed'))
);
CREATE INDEX idx_orders_user_id ON orders(user_id);

View file

@ -2,11 +2,11 @@ package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -40,39 +40,33 @@ class PaymentControllerTest {
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldConfirmPaymentSuccessfully() throws Exception {
void shouldMarkOrderAsPaidSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order();
order.setId(orderId);
order.setPlate("ABC123");
order.setStatus(OrderStatus.PROCESSING);
order.setStatus(OrderStatus.PAID);
order.setAmountPaid(new BigDecimal("49.00"));
when(orderService.confirmPayment(eq(orderId))).thenReturn(order);
when(orderService.markAsPaid(eq(orderId))).thenReturn(order);
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("processing"));
.andExpect(jsonPath("$.status").value("paid"))
.andExpect(jsonPath("$.amountPaid").value(49.00));
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.confirmPayment(eq(orderId)))
when(orderService.markAsPaid(eq(orderId)))
.thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
void shouldReturnSwishInfoUnauthenticated() throws Exception {
mockMvc.perform(get("/api/payment/swish-info"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.number").exists())
.andExpect(jsonPath("$.amount").exists());
}
}

View file

@ -29,7 +29,6 @@ services:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
SWISH_NUMBER: ${SWISH_NUMBER}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}

View file

@ -28,8 +28,7 @@ test.describe('Payment redirect', () => {
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
await page.getByRole('button', { name: 'Jag har betalat' }).click()
await page.getByRole('button', { name: 'Ja, jag har betalat' }).click()
await page.getByRole('button', { name: 'Genomför testbetalning' }).click()
await expect(page).toHaveURL('/orders')
await expect(page.getByText('DEF456').first()).toBeVisible()
@ -42,13 +41,12 @@ test.describe('Payment redirect', () => {
await expect(page).toHaveURL(/\/logga-in/)
})
test('shows Swish payment instructions', async ({ page }) => {
test('shows mock payment note', async ({ page }) => {
await page.goto('/compose?plate=GHI789')
await page.getByLabel('Ditt meddelande').fill('Hej!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
await expect(page.getByText('Swisha till')).toBeVisible()
await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
await expect(page.locator('.payment__note')).toBeVisible()
})
})

View file

@ -50,7 +50,7 @@ const mockOrders = [
email: 'user@example.com',
plate: 'XYZ789',
letterText: 'Vill köpa din bil.',
status: 'processing',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2026-05-14T13:00:00Z',
@ -301,19 +301,4 @@ describe('AdminDashboard', () => {
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID')
})
it('shows Att göra stat for processing orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Att göra')
})
it('highlights processing rows', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin__row')
expect(rows[1].classes()).toContain('admin__row--todo')
})
})

View file

@ -155,24 +155,4 @@ describe('OrdersPage', () => {
expect(badges[0].classes()).toContain('badge--success')
expect(badges[1].classes()).toContain('badge--muted')
})
it('renders processing status correctly', async () => {
const ordersWithProcessing = [
{
id: 'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14',
plate: 'XYZ123',
status: 'processing',
trackingId: null,
createdAt: '2026-05-15T10:00:00Z',
},
]
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, ordersWithProcessing),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hanteras')
const badge = wrapper.find('.badge')
expect(badge.classes()).toContain('badge--primary')
})
})

View file

@ -7,12 +7,10 @@ import OrdersPage from '@/pages/OrdersPage.vue'
vi.mock('@/api/payment', () => ({
payOrder: vi.fn(),
fetchSwishInfo: vi.fn(),
}))
import { payOrder, fetchSwishInfo } from '@/api/payment'
import { payOrder } from '@/api/payment'
const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
function createTestRouter() {
return createRouter({
@ -55,17 +53,11 @@ async function mountPage(orderId = 'order-1', plate = 'ABC123') {
describe('PaymentRedirect', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSwishInfo.mockResolvedValue({
number: '0701234567',
amount: 49,
})
})
it('renders heading and amount', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Betalning')
})
expect(wrapper.text()).toContain('49 kr')
})
@ -74,132 +66,71 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('ABC123')
})
it('shows Swish payment button', async () => {
it('shows payment button', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag har betalat')
})
const button = wrapper.find('.btn--primary')
expect(button.exists()).toBe(true)
expect(button.text()).toBe('Genomför testbetalning')
})
it('shows confirmation dialog after clicking pay button', async () => {
it('shows test payment note', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
expect(wrapper.text()).toContain('testbetalning')
})
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567')
expect(wrapper.text()).toContain('order-1')
})
})
it('can cancel confirmation dialog', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbryt')
})
await wrapper.find('.btn--ghost').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Swisha till')
expect(wrapper.text()).not.toContain('Avbryt')
})
})
it('calls payOrder on confirmation', async () => {
it('calls payOrder on button click', async () => {
mockPayOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
status: 'processing',
status: 'paid',
trackingId: null,
amountPaid: null,
amountPaid: 49.0,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat')
})
const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
expect(mockPayOrder).toHaveBeenCalledWith('order-1')
})
it('shows error on payment confirmation failure', async () => {
mockPayOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat')
})
const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
})
})
it('navigates to orders on success', async () => {
mockPayOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
status: 'processing',
status: 'paid',
trackingId: null,
amountPaid: null,
amountPaid: 49.0,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat')
})
const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders')
})
})
it('displays Swish number from API', async () => {
it('shows error on payment failure', async () => {
mockPayOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('0701234567')
expect(wrapper.text()).toContain('Kunde inte genomföra betalningen')
})
})
it('shows error when swish info fetch fails', async () => {
mockFetchSwishInfo.mockRejectedValue(new Error('Network error'))
it('disables button while paying', async () => {
mockPayOrder.mockImplementation(() => new Promise(() => {}))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte ladda betalningsinformation')
})
const button = wrapper.find('.btn--primary')
await button.trigger('click')
expect(button.attributes('disabled')).toBeDefined()
expect(button.text()).toBe('Bearbetar...')
})
})

View file

@ -1,17 +1,8 @@
import { request } from './client'
import type { Order } from './orders'
export interface SwishInfo {
number: string
amount: number
}
export function payOrder(orderId: string): Promise<Order> {
return request<Order>(`/payment/${orderId}/pay`, {
method: 'POST',
})
}
export function fetchSwishInfo(): Promise<SwishInfo> {
return request<SwishInfo>('/payment/swish-info')
}

View file

@ -18,7 +18,7 @@ const trackingInputValues = reactive<Record<string, string>>({})
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
processing: 'Hanteras',
lookup_started: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
@ -26,8 +26,8 @@ const statusLabels: Record<string, string> = {
const statusBadge: Record<string, string> = {
pending_payment: 'badge--muted',
paid: 'badge--success',
processing: 'badge--primary',
paid: 'badge--primary',
lookup_started: 'badge--primary',
sent: 'badge--success',
delivered: 'badge--success',
failed: 'badge--danger',
@ -36,7 +36,7 @@ const statusBadge: Record<string, string> = {
const allStatuses = [
'pending_payment',
'paid',
'processing',
'lookup_started',
'sent',
'delivered',
'failed',
@ -44,14 +44,16 @@ const allStatuses = [
const stats = computed(() => {
const total = orders.value.length
const todo = orders.value.filter((o) => o.status === 'processing').length
const paid = orders.value.filter((o) =>
['paid', 'sent', 'delivered'].includes(o.status),
['paid', 'lookup_started', 'sent', 'delivered'].includes(o.status),
).length
const pending = orders.value.filter(
(o) => o.status === 'pending_payment',
).length
return { total, todo, paid, pending }
const sent = orders.value.filter(
(o) => o.status === 'sent' || o.status === 'delivered',
).length
return { total, paid, pending, sent }
})
function formatDate(iso: string): string {
@ -142,10 +144,6 @@ onMounted(async () => {
<span class="admin__stat-value">{{ stats.total }}</span>
<span class="admin__stat-label">Totalt</span>
</div>
<div class="admin__stat admin__stat--todo">
<span class="admin__stat-value">{{ stats.todo }}</span>
<span class="admin__stat-label">Att göra</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.paid }}</span>
<span class="admin__stat-label">Betalda</span>
@ -154,6 +152,10 @@ onMounted(async () => {
<span class="admin__stat-value">{{ stats.pending }}</span>
<span class="admin__stat-label">Väntar</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.sent }}</span>
<span class="admin__stat-label">Skickade</span>
</div>
</div>
<p
@ -181,7 +183,6 @@ onMounted(async () => {
class="admin__row"
:class="{
'admin__row--expanded': expandedOrderId === order.id,
'admin__row--todo': order.status === 'processing',
}"
>
<td>{{ formatDate(order.createdAt) }}</td>
@ -345,11 +346,6 @@ onMounted(async () => {
box-shadow: var(--shadow-sm);
}
.admin__stat--todo {
background: var(--color-primary-soft);
border-color: var(--color-primary);
}
.admin__stat-value {
display: block;
font-size: 1.5rem;
@ -414,10 +410,6 @@ onMounted(async () => {
background: var(--color-primary-soft) !important;
}
.admin__row--todo {
border-left: 3px solid var(--color-primary);
}
.admin__row td {
padding: 0.75rem var(--space-md);
color: var(--color-ink);

View file

@ -10,7 +10,7 @@ const error = ref('')
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
processing: 'Hanteras',
lookup_started: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
@ -18,8 +18,8 @@ const statusLabels: Record<string, string> = {
const statusBadge: Record<string, string> = {
pending_payment: 'badge--muted',
paid: 'badge--success',
processing: 'badge--primary',
paid: 'badge--primary',
lookup_started: 'badge--primary',
sent: 'badge--success',
delivered: 'badge--success',
failed: 'badge--danger',

View file

@ -1,37 +1,16 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { payOrder, fetchSwishInfo } from '@/api/payment'
import { payOrder } from '@/api/payment'
const router = useRouter()
const route = useRoute()
const orderId = route.params.orderId as string
const swishNumber = ref('')
const swishAmount = ref(49)
const paying = ref(false)
const error = ref('')
const showConfirmation = ref(false)
onMounted(async () => {
try {
const info = await fetchSwishInfo()
swishNumber.value = info.number
swishAmount.value = info.amount
} catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
}
})
function startPayment() {
showConfirmation.value = true
}
function cancelPayment() {
showConfirmation.value = false
}
async function confirmPayment() {
async function handlePay() {
paying.value = true
error.value = ''
@ -39,7 +18,7 @@ async function confirmPayment() {
await payOrder(orderId)
await router.push({ name: 'orders' })
} catch {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
error.value = 'Kunde inte genomföra betalningen. Försök igen.'
} finally {
paying.value = false
}
@ -57,7 +36,7 @@ async function confirmPayment() {
<div class="payment__summary">
<div class="payment__row">
<span class="payment__label">Att betala</span>
<span class="payment__amount">{{ swishAmount }} kr</span>
<span class="payment__amount">49 kr</span>
</div>
</div>
@ -69,51 +48,17 @@ async function confirmPayment() {
{{ error }}
</div>
<template v-if="!showConfirmation">
<div class="payment__swish">
<p class="payment__swish-label">Swisha till</p>
<p class="payment__swish-number">{{ swishNumber }}</p>
<p class="payment__swish-instruction">
Ange <strong class="payment__order-id">{{ orderId }}</strong>
som meddelande i Swish-appen.
</p>
<p class="payment__swish-instruction">
Tryck sedan knappen nedan för att bekräfta.
</p>
</div>
<button
class="btn btn--primary btn--lg payment__submit"
@click="startPayment"
:disabled="paying"
@click="handlePay"
>
Jag har betalat
{{ paying ? 'Bearbetar...' : 'Genomför testbetalning' }}
</button>
</template>
<template v-else>
<div class="payment__confirm">
<p class="payment__confirm-text">
Jag bekräftar att jag har Swishat {{ swishAmount }} kr till
{{ swishNumber }} med meddelande: {{ orderId }}.
<p class="payment__note">
Detta är en testbetalning i utvecklingsmiljön.
</p>
<div class="payment__confirm-actions">
<button
class="btn btn--ghost payment__confirm-cancel"
:disabled="paying"
@click="cancelPayment"
>
Avbryt
</button>
<button
class="btn btn--primary"
:disabled="paying"
@click="confirmPayment"
>
{{ paying ? 'Bearbetar...' : 'Ja, jag har betalat' }}
</button>
</div>
</div>
</template>
</div>
</div>
</template>
@ -168,73 +113,14 @@ async function confirmPayment() {
color: var(--color-ink);
}
.payment__swish {
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
text-align: center;
}
.payment__swish-label {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.payment__swish-number {
margin: 0 0 var(--space-md) 0;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-ink);
letter-spacing: 0.05em;
}
.payment__swish-instruction {
margin: 0;
font-size: 0.8125rem;
color: var(--color-muted);
line-height: 1.5;
}
.payment__swish-instruction + .payment__swish-instruction {
margin-top: var(--space-xs);
}
.payment__order-id {
word-break: break-all;
color: var(--color-ink);
}
.payment__submit {
width: 100%;
}
.payment__confirm {
padding: var(--space-md) 0;
}
.payment__confirm-text {
margin: 0 0 var(--space-lg) 0;
font-size: 0.9375rem;
color: var(--color-ink);
line-height: 1.6;
}
.payment__confirm-actions {
display: flex;
gap: var(--space-md);
}
.payment__confirm-cancel {
flex: 1;
}
.payment__confirm-actions .btn--primary {
flex: 2;
.payment__note {
margin: var(--space-md) 0 0 0;
font-size: 0.75rem;
color: var(--color-soft);
text-align: center;
}
</style>

View file

@ -18,8 +18,6 @@ export default defineConfig({
},
preview: {
port: 80,
host: true,
allowedHosts: ['frontend', 'localhost'],
proxy: {
'/api': 'http://backend:8080',
},