Enhance checkout flow with new components and validation

- Added AddressForm and CheckoutForm components for user input during checkout.
- Implemented validation using Zod and VeeValidate for billing address fields.
- Created OrderSummary and MockPayPalButton components for order confirmation and payment simulation.
- Updated CartSheet and CartSidebar to navigate to the new checkout page at '/kasse'.
- Introduced new API endpoints for validating checkout data and creating orders.
- Enhanced user experience with responsive design and error handling.

These changes complete the checkout functionality, allowing users to enter billing information, simulate payment, and confirm orders.
This commit is contained in:
Bastian Masanek
2025-11-03 15:38:16 +01:00
parent 47fe14c6cc
commit 527379a2cd
44 changed files with 4957 additions and 142 deletions

View File

@@ -0,0 +1,299 @@
<script setup lang="ts">
import { z } from 'zod'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
/**
* CheckoutForm Component
*
* Features:
* - Pre-fills address from user profile if available
* - Validates all billing address fields (Zod + German error messages)
* - "Save address" checkbox (pre-checked if user has no saved address)
* - Emits @submit event with validated checkout data
*/
// Checkout schema (duplicated from server for client-side validation)
const checkoutSchema = z.object({
salutation: z.enum(['male', 'female', 'other'], {
errorMap: () => ({ message: 'Bitte wähle eine Anrede' }),
}),
firstName: z
.string()
.min(2, 'Vorname muss mindestens 2 Zeichen lang sein')
.max(100, 'Vorname darf maximal 100 Zeichen lang sein'),
lastName: z
.string()
.min(2, 'Nachname muss mindestens 2 Zeichen lang sein')
.max(100, 'Nachname darf maximal 100 Zeichen lang sein'),
dateOfBirth: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Bitte gib ein gültiges Datum ein (YYYY-MM-DD)')
.refine(
(date) => {
const parsed = new Date(date)
const today = new Date()
today.setHours(0, 0, 0, 0)
return !isNaN(parsed.getTime()) && parsed < today
},
{ message: 'Geburtsdatum muss ein gültiges Datum in der Vergangenheit sein' }
),
street: z
.string()
.min(3, 'Straße muss mindestens 3 Zeichen lang sein')
.max(200, 'Straße darf maximal 200 Zeichen lang sein'),
postCode: z
.string()
.regex(/^\d{5}$/, 'Bitte gib eine gültige 5-stellige Postleitzahl ein'),
city: z
.string()
.min(2, 'Stadt muss mindestens 2 Zeichen lang sein')
.max(100, 'Stadt darf maximal 100 Zeichen lang sein'),
countryCode: z
.string()
.length(2, 'Ländercode muss genau 2 Zeichen lang sein')
.default('DE'),
saveAddress: z.boolean().optional().default(false),
})
interface Props {
/**
* Loading state (e.g., during order creation)
*/
loading?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
submit: [data: z.infer<typeof checkoutSchema>]
}>()
const { user } = useAuth()
// Form state
const form = reactive({
salutation: (user.value?.salutation as 'male' | 'female' | 'other') || 'other',
firstName: user.value?.firstName || '',
lastName: user.value?.lastName || '',
dateOfBirth: user.value?.dateOfBirth
? new Date(user.value.dateOfBirth).toISOString().split('T')[0]
: '',
street: user.value?.street || '',
postCode: user.value?.postCode || '',
city: user.value?.city || '',
countryCode: user.value?.countryCode || 'DE',
// Pre-checked if user doesn't have address yet
saveAddress: !user.value?.street,
})
// Validation errors
const errors = ref<Record<string, string>>({})
// Form submission
async function handleSubmit() {
try {
// Validate form data
const validated = await checkoutSchema.parseAsync(form)
// Clear errors
errors.value = {}
// Emit validated data
emit('submit', validated)
} catch (error) {
if (error instanceof z.ZodError) {
// Map Zod errors to field names
errors.value = {}
for (const issue of error.issues) {
const field = issue.path[0] as string
errors.value[field] = issue.message
}
}
}
}
// Helper to check if field has error
function hasError(field: string): boolean {
return !!errors.value[field]
}
// Helper to get error message
function getError(field: string): string {
return errors.value[field] || ''
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Salutation -->
<div class="space-y-2">
<label class="text-sm font-medium text-white">Anrede *</label>
<Select v-model="form.salutation" :disabled="loading">
<SelectTrigger :class="{ 'border-red-500': hasError('salutation') }">
<SelectValue placeholder="Bitte wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male">Herr</SelectItem>
<SelectItem value="female">Frau</SelectItem>
<SelectItem value="other">Keine Angabe</SelectItem>
</SelectContent>
</Select>
<p v-if="hasError('salutation')" class="text-sm text-red-400">
{{ getError('salutation') }}
</p>
</div>
<!-- First Name -->
<div class="space-y-2">
<label for="firstName" class="text-sm font-medium text-white">Vorname *</label>
<Input
id="firstName"
v-model="form.firstName"
type="text"
placeholder="Max"
:disabled="loading"
:class="{ 'border-red-500': hasError('firstName') }"
/>
<p v-if="hasError('firstName')" class="text-sm text-red-400">
{{ getError('firstName') }}
</p>
</div>
<!-- Last Name -->
<div class="space-y-2">
<label for="lastName" class="text-sm font-medium text-white">Nachname *</label>
<Input
id="lastName"
v-model="form.lastName"
type="text"
placeholder="Mustermann"
:disabled="loading"
:class="{ 'border-red-500': hasError('lastName') }"
/>
<p v-if="hasError('lastName')" class="text-sm text-red-400">
{{ getError('lastName') }}
</p>
</div>
<!-- Date of Birth -->
<div class="space-y-2">
<label for="dateOfBirth" class="text-sm font-medium text-white">
Geburtsdatum *
</label>
<Input
id="dateOfBirth"
v-model="form.dateOfBirth"
type="date"
:disabled="loading"
:class="{ 'border-red-500': hasError('dateOfBirth') }"
/>
<p v-if="hasError('dateOfBirth')" class="text-sm text-red-400">
{{ getError('dateOfBirth') }}
</p>
</div>
<!-- Street -->
<div class="space-y-2">
<label for="street" class="text-sm font-medium text-white">
Straße und Hausnummer *
</label>
<Input
id="street"
v-model="form.street"
type="text"
placeholder="Musterstraße 123"
:disabled="loading"
:class="{ 'border-red-500': hasError('street') }"
/>
<p v-if="hasError('street')" class="text-sm text-red-400">
{{ getError('street') }}
</p>
</div>
<!-- Post Code -->
<div class="space-y-2">
<label for="postCode" class="text-sm font-medium text-white">Postleitzahl *</label>
<Input
id="postCode"
v-model="form.postCode"
type="text"
placeholder="74072"
maxlength="5"
:disabled="loading"
:class="{ 'border-red-500': hasError('postCode') }"
/>
<p v-if="hasError('postCode')" class="text-sm text-red-400">
{{ getError('postCode') }}
</p>
</div>
<!-- City -->
<div class="space-y-2">
<label for="city" class="text-sm font-medium text-white">Stadt *</label>
<Input
id="city"
v-model="form.city"
type="text"
placeholder="Heilbronn"
:disabled="loading"
:class="{ 'border-red-500': hasError('city') }"
/>
<p v-if="hasError('city')" class="text-sm text-red-400">
{{ getError('city') }}
</p>
</div>
<!-- Country Code -->
<div class="space-y-2">
<label for="countryCode" class="text-sm font-medium text-white">Land *</label>
<Select v-model="form.countryCode" :disabled="loading">
<SelectTrigger :class="{ 'border-red-500': hasError('countryCode') }">
<SelectValue placeholder="Bitte wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="DE">Deutschland</SelectItem>
<SelectItem value="AT">Österreich</SelectItem>
<SelectItem value="CH">Schweiz</SelectItem>
</SelectContent>
</Select>
<p v-if="hasError('countryCode')" class="text-sm text-red-400">
{{ getError('countryCode') }}
</p>
</div>
<!-- Save Address Checkbox -->
<div class="flex items-start gap-3 pt-2">
<input
id="saveAddress"
v-model="form.saveAddress"
type="checkbox"
:disabled="loading"
class="mt-1 h-4 w-4 rounded border-white/20 bg-white/10 text-experimenta-accent focus:ring-2 focus:ring-experimenta-accent focus:ring-offset-2 focus:ring-offset-experimenta-primary"
/>
<label for="saveAddress" class="text-sm text-white/80 cursor-pointer select-none">
Adresse für zukünftige Bestellungen speichern
</label>
</div>
<!-- Submit Button -->
<Button
type="submit"
:disabled="loading"
class="w-full bg-gradient-button bg-size-300 bg-left hover:bg-right transition-all duration-300 font-bold text-white shadow-lg hover:shadow-2xl"
size="lg"
>
<span v-if="!loading">Weiter zur Zahlung</span>
<span v-else class="flex items-center gap-2">
<div
class="animate-spin rounded-full h-4 w-4 border-2 border-white/20 border-t-white"
/>
Wird verarbeitet...
</span>
</Button>
<!-- Required Fields Info -->
<p class="text-xs text-white/60 text-center">* Pflichtfelder</p>
</form>
</template>