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,309 @@
<script setup lang="ts">
/**
* AddressForm Component
*
* Reusable address form component with VeeValidate integration
* Can be used in checkout flow and profile settings
*
* Features:
* - Salutation dropdown (Herr, Frau, Keine Angabe)
* - Date of birth picker (German format display, YYYY-MM-DD storage)
* - Address fields (street, postCode, city, countryCode)
* - Field-level validation with error display
* - Mobile-first responsive design
*/
import { cn } from '~/lib/utils'
interface AddressData {
salutation: 'male' | 'female' | 'other'
firstName: string
lastName: string
dateOfBirth: string // YYYY-MM-DD format
street: string
postCode: string
city: string
countryCode: string
}
interface Props {
/**
* Address data (v-model)
*/
modelValue: Partial<AddressData>
/**
* Validation errors object { fieldName: errorMessage }
*/
errors?: Record<string, string>
/**
* Disabled state for all fields
*/
disabled?: boolean
/**
* Additional CSS classes
*/
class?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: Partial<AddressData>]
}>()
// Local state for form fields
const localData = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
// Update individual field
const updateField = (field: keyof AddressData, value: string) => {
emit('update:modelValue', {
...props.modelValue,
[field]: value,
})
}
// Salutation options
const salutationOptions = [
{ value: 'male', label: 'Herr' },
{ value: 'female', label: 'Frau' },
{ value: 'other', label: 'Keine Angabe' },
]
// Country options (expandable in future)
const countryOptions = [
{ value: 'DE', label: 'Deutschland' },
{ value: 'AT', label: 'Österreich' },
{ value: 'CH', label: 'Schweiz' },
]
// Get error message for a field
const getError = (field: string) => props.errors?.[field]
// Format date for display (DD.MM.YYYY)
const formatDateForDisplay = (isoDate: string) => {
if (!isoDate) return ''
const [year, month, day] = isoDate.split('-')
return `${day}.${month}.${year}`
}
// Parse date from display format to ISO (YYYY-MM-DD)
const parseDateFromDisplay = (displayDate: string) => {
if (!displayDate) return ''
const parts = displayDate.split('.')
if (parts.length !== 3) return displayDate // Return as-is if not in expected format
const [day, month, year] = parts
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
}
</script>
<template>
<div :class="cn('space-y-4', props.class)">
<!-- Salutation -->
<div class="space-y-2">
<Label for="salutation" class="text-white">
Anrede <span class="text-red">*</span>
</Label>
<Select
:model-value="localData.salutation"
:disabled="disabled"
@update:model-value="updateField('salutation', $event)"
>
<SelectTrigger
id="salutation"
:class="cn(
'bg-purple-dark border-white/20 text-white',
getError('salutation') && 'border-red'
)"
>
<SelectValue placeholder="Bitte wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in salutationOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="getError('salutation')" class="text-xs text-red">
{{ getError('salutation') }}
</p>
</div>
<!-- Name fields (side-by-side on desktop) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- First Name -->
<div class="space-y-2">
<Label for="firstName" class="text-white">
Vorname <span class="text-red">*</span>
</Label>
<Input
id="firstName"
:model-value="localData.firstName"
type="text"
placeholder="Max"
:disabled="disabled"
:class="cn(
'bg-purple-dark border-white/20 text-white placeholder:text-white/40',
getError('firstName') && 'border-red'
)"
@update:model-value="updateField('firstName', $event)"
/>
<p v-if="getError('firstName')" class="text-xs text-red">
{{ getError('firstName') }}
</p>
</div>
<!-- Last Name -->
<div class="space-y-2">
<Label for="lastName" class="text-white">
Nachname <span class="text-red">*</span>
</Label>
<Input
id="lastName"
:model-value="localData.lastName"
type="text"
placeholder="Mustermann"
:disabled="disabled"
:class="cn(
'bg-purple-dark border-white/20 text-white placeholder:text-white/40',
getError('lastName') && 'border-red'
)"
@update:model-value="updateField('lastName', $event)"
/>
<p v-if="getError('lastName')" class="text-xs text-red">
{{ getError('lastName') }}
</p>
</div>
</div>
<!-- Date of Birth -->
<div class="space-y-2">
<Label for="dateOfBirth" class="text-white">
Geburtsdatum <span class="text-red">*</span>
</Label>
<Input
id="dateOfBirth"
:model-value="localData.dateOfBirth"
type="date"
:disabled="disabled"
:class="cn(
'bg-purple-dark border-white/20 text-white placeholder:text-white/40',
getError('dateOfBirth') && 'border-red'
)"
@update:model-value="updateField('dateOfBirth', $event)"
/>
<p v-if="getError('dateOfBirth')" class="text-xs text-red">
{{ getError('dateOfBirth') }}
</p>
</div>
<!-- Street -->
<div class="space-y-2">
<Label for="street" class="text-white">
Straße und Hausnummer <span class="text-red">*</span>
</Label>
<Input
id="street"
:model-value="localData.street"
type="text"
placeholder="Musterstraße 123"
:disabled="disabled"
:class="cn(
'bg-purple-dark border-white/20 text-white placeholder:text-white/40',
getError('street') && 'border-red'
)"
@update:model-value="updateField('street', $event)"
/>
<p v-if="getError('street')" class="text-xs text-red">
{{ getError('street') }}
</p>
</div>
<!-- Post Code and City (side-by-side) -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Post Code -->
<div class="space-y-2">
<Label for="postCode" class="text-white">
PLZ <span class="text-red">*</span>
</Label>
<Input
id="postCode"
:model-value="localData.postCode"
type="text"
placeholder="12345"
:disabled="disabled"
:class="cn(
'bg-purple-dark border-white/20 text-white placeholder:text-white/40',
getError('postCode') && 'border-red'
)"
@update:model-value="updateField('postCode', $event)"
/>
<p v-if="getError('postCode')" class="text-xs text-red">
{{ getError('postCode') }}
</p>
</div>
<!-- City -->
<div class="space-y-2 sm:col-span-2">
<Label for="city" class="text-white">
Stadt <span class="text-red">*</span>
</Label>
<Input
id="city"
:model-value="localData.city"
type="text"
placeholder="Musterstadt"
:disabled="disabled"
:class="cn(
'bg-purple-dark border-white/20 text-white placeholder:text-white/40',
getError('city') && 'border-red'
)"
@update:model-value="updateField('city', $event)"
/>
<p v-if="getError('city')" class="text-xs text-red">
{{ getError('city') }}
</p>
</div>
</div>
<!-- Country -->
<div class="space-y-2">
<Label for="countryCode" class="text-white">
Land <span class="text-red">*</span>
</Label>
<Select
:model-value="localData.countryCode"
:disabled="disabled"
@update:model-value="updateField('countryCode', $event)"
>
<SelectTrigger
id="countryCode"
:class="cn(
'bg-purple-dark border-white/20 text-white',
getError('countryCode') && 'border-red'
)"
>
<SelectValue placeholder="Bitte wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in countryOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="getError('countryCode')" class="text-xs text-red">
{{ getError('countryCode') }}
</p>
</div>
</div>
</template>