- Refactored CheckoutForm.vue to utilize an extended user type, incorporating additional address fields for improved user data handling. - Updated OrderSummary.vue to conditionally display salutation based on user input. - Standardized error alert styling across multiple pages, changing variant from 'destructive' to 'error' for consistency. - Adjusted button styles in various components to align with the new 'experimenta' variant. These changes aim to improve user experience by ensuring accurate data representation and consistent UI elements across the checkout and order processes.
325 lines
12 KiB
Vue
325 lines
12 KiB
Vue
<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>
|