You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

324 lines
12 KiB

<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
}
defineProps<Props>()
const emit = defineEmits<{
submit: [data: z.infer<typeof checkoutSchema>]
}>()
const { user } = useAuth()
// Extended user type with address fields
type ExtendedUser = typeof user.value & {
id?: string
firstName?: string
lastName?: string
email?: string
salutation?: 'male' | 'female' | 'other' | null
dateOfBirth?: Date | string | null
street?: string | null
postCode?: string | null
city?: string | null
countryCode?: string | null
}
const extendedUser = user.value as ExtendedUser
// Form state
const form = reactive({
salutation: (extendedUser?.salutation as 'male' | 'female' | 'other') || 'other',
firstName: extendedUser?.firstName || '',
lastName: extendedUser?.lastName || '',
dateOfBirth: extendedUser?.dateOfBirth
? new Date(extendedUser.dateOfBirth).toISOString().split('T')[0]
: '',
street: extendedUser?.street || '',
postCode: extendedUser?.postCode || '',
city: extendedUser?.city || '',
countryCode: extendedUser?.countryCode || 'DE',
// Pre-checked if user doesn't have address yet
saveAddress: !extendedUser?.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-warning/50': 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="form-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-warning flex-shrink-0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ 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-warning/50': hasError('firstName') }"
/>
<p v-if="hasError('firstName')" class="form-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-warning flex-shrink-0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ 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-warning/50': hasError('lastName') }"
/>
<p v-if="hasError('lastName')" class="form-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-warning flex-shrink-0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ 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-warning/50': hasError('dateOfBirth') }"
/>
<p v-if="hasError('dateOfBirth')" class="form-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-warning flex-shrink-0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ 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-warning/50': hasError('street') }"
/>
<p v-if="hasError('street')" class="form-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-warning flex-shrink-0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ 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-warning/50': hasError('postCode') }"
/>
<p v-if="hasError('postCode')" class="form-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-warning flex-shrink-0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ 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-warning/50': hasError('city') }"
/>
<p v-if="hasError('city')" class="form-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-warning flex-shrink-0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ 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-warning/50': 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="form-error">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-warning flex-shrink-0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ 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"
variant="experimenta"
size="experimenta"
class="w-full"
>
<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>