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
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>
|
|
|