Adds an anonymous guest checkout flow so a customer can order a bilhälsning without creating an account. Payment via Swish (QR + payment link). Backend: - GuestOrderController: POST /api/guest-orders (public, no auth) - CreateGuestOrderRequest / GuestOrderResponse DTOs - Order entity: guest_email, guest_token (UUID), nullable user_id - OrderRepository: findByGuestToken, findByGuestEmail - OrderService: createGuestOrder, getGuestOrder by token - SecurityConfig: /api/guest-orders/** permitAll - V12 migration: drops user_id NOT NULL, adds guest_email + guest_token with partial unique index (backfill-safe for existing user orders) Frontend: - GuestCheckoutPage: plate lookup + order form (no login) - GuestPaymentRedirect: Swish QR + payment link + status polling - GuestOrderPage: order status by guest token - guestOrders.ts API client - router: /guest/* public routes - vite.config: dev proxy for /api/guest-orders Verification: - [x] vue-tsc type-check passes (exit 0) - [ ] Backend Java compiles (no JDK/docker in agent sandbox) - [ ] Flyway V12 migration applies cleanly - [ ] End-to-end POST /api/guest-orders -> 201 -> Swish -> status Frontend type-checks but backend has NOT been compiled or run yet. This PR is for review; backend smoke test pending in a docker environment.
188 lines
4.6 KiB
Vue
188 lines
4.6 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { createGuestOrder } from '@/api/guestOrders'
|
|
|
|
const router = useRouter()
|
|
|
|
const plate = ref('')
|
|
const letterText = ref('')
|
|
const email = ref('')
|
|
const submitting = ref(false)
|
|
const errorMessage = ref('')
|
|
|
|
const maxChars = 1000
|
|
const charCount = computed(() => letterText.value.length)
|
|
|
|
const PLATE_RE = /^[A-Za-z]{3}\d{2}[A-Za-z0-9]$/
|
|
|
|
const canSubmit = computed(
|
|
() =>
|
|
PLATE_RE.test(plate.value.trim()) &&
|
|
letterText.value.trim().length > 0 &&
|
|
/\S+@\S+\.\S+/.test(email.value.trim()) &&
|
|
!submitting.value,
|
|
)
|
|
|
|
async function handleSubmit() {
|
|
if (!canSubmit.value) return
|
|
|
|
submitting.value = true
|
|
errorMessage.value = ''
|
|
|
|
try {
|
|
const order = await createGuestOrder(
|
|
plate.value.trim(),
|
|
letterText.value,
|
|
email.value.trim(),
|
|
)
|
|
// Token rides in the query string so the payment page survives refresh.
|
|
await router.push({
|
|
name: 'guest-payment',
|
|
params: { orderId: order.id },
|
|
query: { token: order.guestToken, plate: order.plate },
|
|
})
|
|
} catch {
|
|
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="guest-checkout">
|
|
<div class="guest-checkout__card">
|
|
<h1 class="guest-checkout__title">Skicka ett brev</h1>
|
|
<p class="guest-checkout__subtitle">
|
|
49 kr — betala med Swish. Inget konto behövs.
|
|
</p>
|
|
|
|
<form class="guest-checkout__form" @submit.prevent="handleSubmit">
|
|
<div class="field">
|
|
<label for="plate" class="field__label">Registreringsnummer</label>
|
|
<input
|
|
id="plate"
|
|
v-model="plate"
|
|
type="text"
|
|
class="field__input"
|
|
placeholder="ABC123"
|
|
maxlength="6"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="letter" class="field__label">Ditt meddelande</label>
|
|
<textarea
|
|
id="letter"
|
|
v-model="letterText"
|
|
class="field__input guest-checkout__textarea"
|
|
:maxlength="maxChars"
|
|
rows="10"
|
|
placeholder="Skriv ditt meddelande här..."
|
|
></textarea>
|
|
<p class="field__hint guest-checkout__counter">
|
|
{{ charCount }} / {{ maxChars }} tecken
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="email" class="field__label"
|
|
>E-post (för kvitto och orderlänk)</label
|
|
>
|
|
<input
|
|
id="email"
|
|
v-model="email"
|
|
type="email"
|
|
class="field__input"
|
|
placeholder="namn@example.se"
|
|
autocomplete="email"
|
|
/>
|
|
<p class="field__hint">
|
|
Vi skickar en magisk länk så du kan följa ditt brev senare.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="errorMessage" class="message message--error">
|
|
{{ errorMessage }}
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
class="btn btn--primary btn--lg guest-checkout__submit"
|
|
:disabled="!canSubmit"
|
|
>
|
|
{{ submitting ? 'Skickar...' : 'Fortsätt till betalning' }}
|
|
</button>
|
|
|
|
<p class="guest-checkout__login-hint">
|
|
Har du redan ett konto?
|
|
<RouterLink to="/logga-in">Logga in</RouterLink>
|
|
</p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.guest-checkout {
|
|
max-width: 32rem;
|
|
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto;
|
|
padding: 0 var(--page-gutter);
|
|
}
|
|
|
|
.guest-checkout__card {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-xl);
|
|
padding: var(--space-xl);
|
|
box-shadow: var(--shadow-card);
|
|
}
|
|
|
|
.guest-checkout__title {
|
|
margin: 0 0 var(--space-xs) 0;
|
|
font-size: 1.5rem;
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.guest-checkout__subtitle {
|
|
margin: 0 0 var(--space-xl) 0;
|
|
color: var(--color-muted);
|
|
font-size: 0.9375rem;
|
|
}
|
|
|
|
.guest-checkout__form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-md);
|
|
}
|
|
|
|
.guest-checkout__textarea {
|
|
resize: vertical;
|
|
min-height: 10rem;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.guest-checkout__counter {
|
|
text-align: right;
|
|
}
|
|
|
|
.guest-checkout__submit {
|
|
width: 100%;
|
|
margin-top: var(--space-sm);
|
|
}
|
|
|
|
.guest-checkout__login-hint {
|
|
text-align: center;
|
|
margin: var(--space-sm) 0 0 0;
|
|
font-size: 0.875rem;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.guest-checkout__login-hint a {
|
|
color: var(--color-primary);
|
|
text-decoration: underline;
|
|
}
|
|
</style>
|