Browse Source
- 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.main
44 changed files with 4964 additions and 149 deletions
@ -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> |
|||
@ -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> |
|||
@ -0,0 +1,187 @@ |
|||
<script setup lang="ts"> |
|||
/** |
|||
* OrderSummary Component |
|||
* |
|||
* Displays order details including: |
|||
* - Product list with quantities and prices |
|||
* - Subtotal, VAT, and total |
|||
* - Billing address |
|||
* |
|||
* Used on: |
|||
* - Order confirmation page (/bestellung/bestaetigen/[orderId]) |
|||
* - Order success page (/bestellung/erfolg/[orderId]) |
|||
*/ |
|||
|
|||
interface OrderItem { |
|||
id: string |
|||
productId: string |
|||
quantity: number |
|||
priceSnapshot: string |
|||
productSnapshot: { |
|||
name: string |
|||
description?: string |
|||
} |
|||
product?: { |
|||
name: string |
|||
imageUrl?: string | null |
|||
} |
|||
subtotal: number |
|||
} |
|||
|
|||
interface BillingAddress { |
|||
salutation: 'male' | 'female' | 'other' |
|||
firstName: string |
|||
lastName: string |
|||
dateOfBirth: string |
|||
street: string |
|||
postCode: string |
|||
city: string |
|||
countryCode: string |
|||
} |
|||
|
|||
interface Order { |
|||
id: string |
|||
orderNumber: string |
|||
totalAmount: string | number |
|||
status: string |
|||
billingAddress: BillingAddress |
|||
items: OrderItem[] |
|||
createdAt: Date | string |
|||
} |
|||
|
|||
interface Props { |
|||
order: Order |
|||
/** |
|||
* Show billing address section |
|||
*/ |
|||
showAddress?: boolean |
|||
} |
|||
|
|||
const props = withDefaults(defineProps<Props>(), { |
|||
showAddress: true, |
|||
}) |
|||
|
|||
// Format currency in EUR |
|||
const formatCurrency = (amount: number | string) => { |
|||
const value = typeof amount === 'string' ? Number.parseFloat(amount) : amount |
|||
return new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(value) |
|||
} |
|||
|
|||
// Calculate total from items (for client-side display) |
|||
const total = computed(() => { |
|||
return typeof props.order.totalAmount === 'string' |
|||
? Number.parseFloat(props.order.totalAmount) |
|||
: props.order.totalAmount |
|||
}) |
|||
|
|||
// Calculate VAT (7% already included in total) |
|||
const vatAmount = computed(() => { |
|||
return total.value * (0.07 / 1.07) |
|||
}) |
|||
|
|||
// Format salutation |
|||
const formatSalutation = (salutation: string) => { |
|||
const map: Record<string, string> = { |
|||
male: 'Herr', |
|||
female: 'Frau', |
|||
other: 'Keine Angabe', |
|||
} |
|||
return map[salutation] || salutation |
|||
} |
|||
|
|||
// Format date |
|||
const formatDate = (date: Date | string) => { |
|||
const d = typeof date === 'string' ? new Date(date) : date |
|||
return new Intl.DateTimeFormat('de-DE', { |
|||
year: 'numeric', |
|||
month: 'long', |
|||
day: 'numeric', |
|||
hour: '2-digit', |
|||
minute: '2-digit', |
|||
}).format(d) |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="space-y-6"> |
|||
<!-- Order Header --> |
|||
<div class="pb-4 border-b border-white/20"> |
|||
<h2 class="text-2xl font-bold text-white">Bestellübersicht</h2> |
|||
<p class="text-sm text-white/60 mt-1"> |
|||
Bestellnummer: <span class="font-mono text-white/80">{{ order.orderNumber }}</span> |
|||
</p> |
|||
<p class="text-sm text-white/60"> |
|||
Erstellt am: {{ formatDate(order.createdAt) }} |
|||
</p> |
|||
</div> |
|||
|
|||
<!-- Order Items --> |
|||
<div class="space-y-4"> |
|||
<h3 class="text-lg font-semibold text-white">Artikel</h3> |
|||
<div class="space-y-3"> |
|||
<div |
|||
v-for="item in order.items" |
|||
:key="item.id" |
|||
class="flex items-start justify-between gap-4 p-4 rounded-lg bg-white/5 border border-white/10" |
|||
> |
|||
<div class="flex-1"> |
|||
<h4 class="font-medium text-white"> |
|||
{{ item.productSnapshot?.name || item.product?.name }} |
|||
</h4> |
|||
<p class="text-sm text-white/60 mt-1"> |
|||
{{ item.quantity }}x {{ formatCurrency(item.priceSnapshot) }} |
|||
</p> |
|||
</div> |
|||
<div class="text-right"> |
|||
<p class="font-semibold text-white"> |
|||
{{ formatCurrency(item.subtotal) }} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Price Breakdown --> |
|||
<div class="space-y-3 pt-4 border-t border-white/20"> |
|||
<!-- Subtotal --> |
|||
<div class="flex items-center justify-between text-white/80"> |
|||
<span class="text-sm">Zwischensumme</span> |
|||
<span class="font-medium">{{ formatCurrency(total) }}</span> |
|||
</div> |
|||
|
|||
<!-- VAT (included) --> |
|||
<div class="flex items-center justify-between text-white/60 text-sm"> |
|||
<span>inkl. MwSt. (7%)</span> |
|||
<span>{{ formatCurrency(vatAmount) }}</span> |
|||
</div> |
|||
|
|||
<!-- Total --> |
|||
<div class="flex items-center justify-between pt-3 border-t border-white/20"> |
|||
<span class="text-lg font-bold text-white">Gesamt</span> |
|||
<span class="text-2xl font-bold text-experimenta-accent"> |
|||
{{ formatCurrency(total) }} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Billing Address --> |
|||
<div v-if="showAddress" class="space-y-3 pt-6 border-t border-white/20"> |
|||
<h3 class="text-lg font-semibold text-white">Rechnungsadresse</h3> |
|||
<div class="p-4 rounded-lg bg-white/5 border border-white/10 space-y-1 text-sm"> |
|||
<p class="text-white"> |
|||
{{ formatSalutation(order.billingAddress.salutation) }} |
|||
{{ order.billingAddress.firstName }} |
|||
{{ order.billingAddress.lastName }} |
|||
</p> |
|||
<p class="text-white/80">{{ order.billingAddress.street }}</p> |
|||
<p class="text-white/80"> |
|||
{{ order.billingAddress.postCode }} {{ order.billingAddress.city }} |
|||
</p> |
|||
<p class="text-white/80">{{ order.billingAddress.countryCode }}</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,149 @@ |
|||
<script setup lang="ts"> |
|||
/** |
|||
* MockPayPalButton Component |
|||
* |
|||
* Simulates a PayPal payment button for MVP testing. |
|||
* NO real PayPal API integration - just UI simulation. |
|||
* |
|||
* Features: |
|||
* - PayPal-like button styling (gold/blue gradient) |
|||
* - Simulates redirect to PayPal (shows URL flash) |
|||
* - Auto-returns after 2 seconds with "success" |
|||
* - Emits @success event when "payment" completes |
|||
*/ |
|||
|
|||
interface Props { |
|||
/** |
|||
* Order ID for the payment |
|||
*/ |
|||
orderId: string |
|||
/** |
|||
* Total amount to display |
|||
*/ |
|||
amount?: number | string |
|||
/** |
|||
* Loading state (disables button) |
|||
*/ |
|||
loading?: boolean |
|||
} |
|||
|
|||
const props = defineProps<Props>() |
|||
|
|||
const emit = defineEmits<{ |
|||
success: [] |
|||
}>() |
|||
|
|||
const isProcessing = ref(false) |
|||
const showPayPalSimulation = ref(false) |
|||
|
|||
// Format currency |
|||
const formatCurrency = (amount: number | string) => { |
|||
const value = typeof amount === 'string' ? Number.parseFloat(amount) : amount |
|||
return new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(value) |
|||
} |
|||
|
|||
// Simulate PayPal payment flow |
|||
async function handlePayment() { |
|||
isProcessing.value = true |
|||
showPayPalSimulation.value = true |
|||
|
|||
// Simulate redirect to PayPal (show fake URL for 1 second) |
|||
await new Promise((resolve) => setTimeout(resolve, 1000)) |
|||
|
|||
// Simulate PayPal processing (1 second) |
|||
await new Promise((resolve) => setTimeout(resolve, 1000)) |
|||
|
|||
// "Payment" successful |
|||
showPayPalSimulation.value = false |
|||
isProcessing.value = false |
|||
|
|||
// Emit success event |
|||
emit('success') |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="space-y-4"> |
|||
<!-- PayPal Button (Mock) --> |
|||
<button |
|||
v-if="!showPayPalSimulation" |
|||
type="button" |
|||
:disabled="loading || isProcessing" |
|||
@click="handlePayment" |
|||
class="w-full rounded-lg px-6 py-4 text-lg font-bold text-white shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed" |
|||
:class="{ |
|||
'bg-gradient-to-r from-[#0070ba] to-[#003087] hover:from-[#003087] hover:to-[#001c64]': |
|||
!isProcessing, |
|||
'bg-gray-600 cursor-wait': isProcessing, |
|||
}" |
|||
> |
|||
<span v-if="!isProcessing" class="flex items-center justify-center gap-2"> |
|||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor"> |
|||
<path |
|||
d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944 3.72a.77.77 0 0 1 .76-.633h8.433c2.76 0 4.633.578 5.575 1.716.94 1.136 1.052 2.73.337 4.745-.713 2.015-1.936 3.533-3.639 4.515-1.703.98-3.818 1.47-6.286 1.47H8.316c-.438 0-.823.314-.906.74l-.97 4.742a.641.641 0 0 1-.633.522h-.73z" |
|||
/> |
|||
<path |
|||
d="M19.175 7.715c-.027.183-.057.364-.09.546-1.06 5.485-4.678 7.381-9.303 7.381H7.737a.914.914 0 0 0-.9.781l-1.238 7.854a.491.491 0 0 0 .485.567h3.42c.383 0 .71-.275.77-.648l.032-.165.611-3.878.039-.213c.06-.373.387-.648.77-.648h.485c4.042 0 7.205-1.642 8.127-6.393.385-1.986.186-3.645-.816-4.812a4.024 4.024 0 0 0-1.037-.85z" |
|||
opacity=".7" |
|||
/> |
|||
</svg> |
|||
<span>Mit PayPal bezahlen</span> |
|||
<span v-if="amount" class="text-white/90">({{ formatCurrency(amount) }})</span> |
|||
</span> |
|||
<span v-else class="flex items-center justify-center gap-2"> |
|||
<div |
|||
class="animate-spin rounded-full h-5 w-5 border-2 border-white/20 border-t-white" |
|||
/> |
|||
Verbinde mit PayPal... |
|||
</span> |
|||
</button> |
|||
|
|||
<!-- PayPal Simulation Overlay --> |
|||
<div |
|||
v-if="showPayPalSimulation" |
|||
class="rounded-lg border-2 border-[#0070ba] bg-white p-8 text-center space-y-4" |
|||
> |
|||
<div class="flex items-center justify-center gap-2 mb-4"> |
|||
<svg class="w-12 h-12 text-[#0070ba]" viewBox="0 0 24 24" fill="currentColor"> |
|||
<path |
|||
d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944 3.72a.77.77 0 0 1 .76-.633h8.433c2.76 0 4.633.578 5.575 1.716.94 1.136 1.052 2.73.337 4.745-.713 2.015-1.936 3.533-3.639 4.515-1.703.98-3.818 1.47-6.286 1.47H8.316c-.438 0-.823.314-.906.74l-.97 4.742a.641.641 0 0 1-.633.522h-.73z" |
|||
/> |
|||
<path |
|||
d="M19.175 7.715c-.027.183-.057.364-.09.546-1.06 5.485-4.678 7.381-9.303 7.381H7.737a.914.914 0 0 0-.9.781l-1.238 7.854a.491.491 0 0 0 .485.567h3.42c.383 0 .71-.275.77-.648l.032-.165.611-3.878.039-.213c.06-.373.387-.648.77-.648h.485c4.042 0 7.205-1.642 8.127-6.393.385-1.986.186-3.645-.816-4.812a4.024 4.024 0 0 0-1.037-.85z" |
|||
opacity=".7" |
|||
/> |
|||
</svg> |
|||
<span class="text-2xl font-bold text-[#0070ba]">PayPal</span> |
|||
</div> |
|||
|
|||
<div class="animate-pulse"> |
|||
<div |
|||
class="animate-spin rounded-full h-12 w-12 border-4 border-[#0070ba]/20 border-t-[#0070ba] mx-auto mb-4" |
|||
/> |
|||
<p class="text-lg font-semibold text-gray-800">Zahlung wird verarbeitet...</p> |
|||
<p class="text-sm text-gray-600 mt-2"> |
|||
Du wirst zu PayPal weitergeleitet und kehrst automatisch zurück. |
|||
</p> |
|||
</div> |
|||
|
|||
<!-- Mock PayPal URL --> |
|||
<div class="mt-4 p-2 bg-gray-100 rounded text-xs font-mono text-gray-600"> |
|||
https://www.paypal.com/checkoutnow?token=EC-MOCK{{ Date.now() }} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Info Text --> |
|||
<div class="text-center space-y-2"> |
|||
<p class="text-sm text-white/60"> |
|||
<strong class="text-experimenta-accent">Hinweis (MVP):</strong> Dies ist eine |
|||
Test-Zahlung. Es wird kein echtes Geld abgebucht. |
|||
</p> |
|||
<p class="text-xs text-white/40"> |
|||
Die echte PayPal-Integration erfolgt in Phase 7 des Projekts. |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,41 @@ |
|||
<script setup lang="ts"> |
|||
import { type HTMLAttributes, computed } from 'vue' |
|||
import { Checkbox as CheckboxPrimitive, CheckboxIndicator } from 'reka-ui' |
|||
import { Check } from 'lucide-vue-next' |
|||
import { cn } from '@/lib/utils' |
|||
|
|||
interface CheckboxProps { |
|||
checked?: boolean |
|||
disabled?: boolean |
|||
class?: HTMLAttributes['class'] |
|||
id?: string |
|||
} |
|||
|
|||
const props = defineProps<CheckboxProps>() |
|||
|
|||
const emit = defineEmits<{ |
|||
'update:checked': [value: boolean] |
|||
}>() |
|||
|
|||
const delegatedProps = computed(() => { |
|||
const { class: _, ...delegated } = props |
|||
return delegated |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<CheckboxPrimitive |
|||
v-bind="delegatedProps" |
|||
:class=" |
|||
cn( |
|||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', |
|||
props.class |
|||
) |
|||
" |
|||
@update:checked="emit('update:checked', $event)" |
|||
> |
|||
<CheckboxIndicator class="flex items-center justify-center text-current"> |
|||
<Check class="h-4 w-4" /> |
|||
</CheckboxIndicator> |
|||
</CheckboxPrimitive> |
|||
</template> |
|||
@ -0,0 +1 @@ |
|||
export { default as Checkbox } from './Checkbox.vue' |
|||
@ -0,0 +1,31 @@ |
|||
<script setup lang="ts"> |
|||
import { type HTMLAttributes, computed } from 'vue' |
|||
import { Label as LabelPrimitive } from 'reka-ui' |
|||
import { cn } from '@/lib/utils' |
|||
|
|||
interface LabelProps { |
|||
for?: string |
|||
class?: HTMLAttributes['class'] |
|||
} |
|||
|
|||
const props = defineProps<LabelProps>() |
|||
|
|||
const delegatedProps = computed(() => { |
|||
const { class: _, ...delegated } = props |
|||
return delegated |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<LabelPrimitive |
|||
v-bind="delegatedProps" |
|||
:class=" |
|||
cn( |
|||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', |
|||
props.class |
|||
) |
|||
" |
|||
> |
|||
<slot /> |
|||
</LabelPrimitive> |
|||
</template> |
|||
@ -0,0 +1 @@ |
|||
export { default as Label } from './Label.vue' |
|||
@ -0,0 +1,22 @@ |
|||
<script setup lang="ts"> |
|||
import { SelectRoot } from 'reka-ui' |
|||
|
|||
const props = defineProps<{ |
|||
modelValue?: string |
|||
disabled?: boolean |
|||
}>() |
|||
|
|||
const emit = defineEmits<{ |
|||
'update:modelValue': [value: string] |
|||
}>() |
|||
</script> |
|||
|
|||
<template> |
|||
<SelectRoot |
|||
:model-value="modelValue" |
|||
:disabled="disabled" |
|||
@update:model-value="emit('update:modelValue', $event)" |
|||
> |
|||
<slot /> |
|||
</SelectRoot> |
|||
</template> |
|||
@ -0,0 +1,43 @@ |
|||
<script setup lang="ts"> |
|||
import { type HTMLAttributes, computed } from 'vue' |
|||
import { SelectContent as SelectContentPrimitive, SelectPortal, SelectViewport } from 'reka-ui' |
|||
import { cn } from '@/lib/utils' |
|||
|
|||
interface SelectContentProps { |
|||
position?: 'popper' | 'item-aligned' |
|||
class?: HTMLAttributes['class'] |
|||
} |
|||
|
|||
const props = withDefaults(defineProps<SelectContentProps>(), { |
|||
position: 'popper', |
|||
}) |
|||
|
|||
const delegatedProps = computed(() => { |
|||
const { class: _, ...delegated } = props |
|||
return delegated |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<SelectPortal> |
|||
<SelectContentPrimitive |
|||
v-bind="delegatedProps" |
|||
:class=" |
|||
cn( |
|||
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', |
|||
props.position === 'popper' && |
|||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', |
|||
props.class |
|||
) |
|||
" |
|||
> |
|||
<SelectViewport |
|||
:class=" |
|||
cn('p-1', props.position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)]') |
|||
" |
|||
> |
|||
<slot /> |
|||
</SelectViewport> |
|||
</SelectContentPrimitive> |
|||
</SelectPortal> |
|||
</template> |
|||
@ -0,0 +1,41 @@ |
|||
<script setup lang="ts"> |
|||
import { type HTMLAttributes, computed } from 'vue' |
|||
import { SelectItem as SelectItemPrimitive, SelectItemIndicator, SelectItemText } from 'reka-ui' |
|||
import { Check } from 'lucide-vue-next' |
|||
import { cn } from '@/lib/utils' |
|||
|
|||
interface SelectItemProps { |
|||
value: string |
|||
disabled?: boolean |
|||
class?: HTMLAttributes['class'] |
|||
} |
|||
|
|||
const props = defineProps<SelectItemProps>() |
|||
|
|||
const delegatedProps = computed(() => { |
|||
const { class: _, ...delegated } = props |
|||
return delegated |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<SelectItemPrimitive |
|||
v-bind="delegatedProps" |
|||
:class=" |
|||
cn( |
|||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', |
|||
props.class |
|||
) |
|||
" |
|||
> |
|||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> |
|||
<SelectItemIndicator> |
|||
<Check class="h-4 w-4" /> |
|||
</SelectItemIndicator> |
|||
</span> |
|||
|
|||
<SelectItemText> |
|||
<slot /> |
|||
</SelectItemText> |
|||
</SelectItemPrimitive> |
|||
</template> |
|||
@ -0,0 +1,33 @@ |
|||
<script setup lang="ts"> |
|||
import { type HTMLAttributes, computed } from 'vue' |
|||
import { SelectTrigger as SelectTriggerPrimitive } from 'reka-ui' |
|||
import { ChevronDown } from 'lucide-vue-next' |
|||
import { cn } from '@/lib/utils' |
|||
|
|||
interface SelectTriggerProps { |
|||
disabled?: boolean |
|||
class?: HTMLAttributes['class'] |
|||
} |
|||
|
|||
const props = defineProps<SelectTriggerProps>() |
|||
|
|||
const delegatedProps = computed(() => { |
|||
const { class: _, ...delegated } = props |
|||
return delegated |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<SelectTriggerPrimitive |
|||
v-bind="delegatedProps" |
|||
:class=" |
|||
cn( |
|||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', |
|||
props.class |
|||
) |
|||
" |
|||
> |
|||
<slot /> |
|||
<ChevronDown class="h-4 w-4 opacity-50" /> |
|||
</SelectTriggerPrimitive> |
|||
</template> |
|||
@ -0,0 +1,15 @@ |
|||
<script setup lang="ts"> |
|||
import { SelectValue as SelectValuePrimitive } from 'reka-ui' |
|||
|
|||
interface SelectValueProps { |
|||
placeholder?: string |
|||
} |
|||
|
|||
defineProps<SelectValueProps>() |
|||
</script> |
|||
|
|||
<template> |
|||
<SelectValuePrimitive :placeholder="placeholder"> |
|||
<slot /> |
|||
</SelectValuePrimitive> |
|||
</template> |
|||
@ -0,0 +1,5 @@ |
|||
export { default as Select } from './Select.vue' |
|||
export { default as SelectTrigger } from './SelectTrigger.vue' |
|||
export { default as SelectValue } from './SelectValue.vue' |
|||
export { default as SelectContent } from './SelectContent.vue' |
|||
export { default as SelectItem } from './SelectItem.vue' |
|||
@ -0,0 +1,111 @@ |
|||
/** |
|||
* VeeValidate Form Validation Composable |
|||
* |
|||
* Provides convenient setup for VeeValidate with Zod schemas and |
|||
* German error messages. |
|||
* |
|||
* @example |
|||
* ```vue
|
|||
* <script setup lang="ts"> |
|||
* import { z } from 'zod' |
|||
* import { useForm } from 'vee-validate' |
|||
* import { toTypedSchema } from '@vee-validate/zod' |
|||
* |
|||
* // Define Zod schema
|
|||
* const checkoutSchema = z.object({ |
|||
* street: z.string().min(3, 'Straße muss mindestens 3 Zeichen lang sein'), |
|||
* postCode: z.string().regex(/^\d{5}$/, 'Ungültige Postleitzahl'), |
|||
* city: z.string().min(2), |
|||
* }) |
|||
* |
|||
* // Setup form with validation
|
|||
* const { handleSubmit, errors, defineField } = useForm({ |
|||
* validationSchema: toTypedSchema(checkoutSchema), |
|||
* }) |
|||
* |
|||
* // Define fields
|
|||
* const [street, streetAttrs] = defineField('street') |
|||
* const [postCode, postCodeAttrs] = defineField('postCode') |
|||
* |
|||
* // Submit handler
|
|||
* const onSubmit = handleSubmit(async (values) => { |
|||
* console.log('Form values:', values) |
|||
* }) |
|||
* </script> |
|||
* |
|||
* <template> |
|||
* <form @submit="onSubmit"> |
|||
* <input v-model="street" v-bind="streetAttrs" /> |
|||
* <span v-if="errors.street">{{ errors.street }}</span> |
|||
* </form> |
|||
* </template> |
|||
* ``` |
|||
*/ |
|||
|
|||
import { toTypedSchema } from '@vee-validate/zod' |
|||
import type { z } from 'zod' |
|||
|
|||
/** |
|||
* Configure VeeValidate with Zod schema |
|||
* |
|||
* This is a convenience wrapper around toTypedSchema from @vee-validate/zod |
|||
* that provides type-safe form validation with Zod schemas. |
|||
* |
|||
* @param schema - Zod schema for form validation |
|||
* @returns Typed schema for VeeValidate |
|||
* |
|||
* @example |
|||
* const validationSchema = useZodSchema(checkoutSchema) |
|||
* const form = useForm({ validationSchema }) |
|||
*/ |
|||
export function useZodSchema<T extends z.ZodTypeAny>(schema: T) { |
|||
return toTypedSchema(schema) |
|||
} |
|||
|
|||
/** |
|||
* Check if a form field has an error |
|||
* |
|||
* @param errors - VeeValidate errors object |
|||
* @param fieldName - Field name to check |
|||
* @returns true if field has an error |
|||
*/ |
|||
export function hasFieldError( |
|||
errors: Record<string, string | undefined>, |
|||
fieldName: string |
|||
): boolean { |
|||
return !!errors[fieldName] |
|||
} |
|||
|
|||
/** |
|||
* Get error message for a field |
|||
* |
|||
* @param errors - VeeValidate errors object |
|||
* @param fieldName - Field name to get error for |
|||
* @returns Error message or empty string |
|||
*/ |
|||
export function getFieldError( |
|||
errors: Record<string, string | undefined>, |
|||
fieldName: string |
|||
): string { |
|||
return errors[fieldName] || '' |
|||
} |
|||
|
|||
/** |
|||
* Check if form is valid (no errors) |
|||
* |
|||
* @param errors - VeeValidate errors object |
|||
* @returns true if form has no errors |
|||
*/ |
|||
export function isFormValid(errors: Record<string, string | undefined>): boolean { |
|||
return Object.keys(errors).length === 0 |
|||
} |
|||
|
|||
/** |
|||
* Get all error messages as an array |
|||
* |
|||
* @param errors - VeeValidate errors object |
|||
* @returns Array of error messages |
|||
*/ |
|||
export function getAllErrors(errors: Record<string, string | undefined>): string[] { |
|||
return Object.values(errors).filter((error): error is string => !!error) |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
// middleware/auth.ts
|
|||
|
|||
/** |
|||
* Authentication middleware |
|||
* |
|||
* Protects routes from unauthenticated access |
|||
* |
|||
* Usage in pages: |
|||
* |
|||
* definePageMeta({ |
|||
* middleware: 'auth' |
|||
* }) |
|||
*/ |
|||
|
|||
export default defineNuxtRouteMiddleware(async (to, from) => { |
|||
const { loggedIn } = useUserSession() |
|||
|
|||
// Not logged in - redirect to auth page
|
|||
if (!loggedIn.value) { |
|||
// Store intended destination for post-login redirect
|
|||
useCookie('redirect_after_login', { |
|||
maxAge: 600, // 10 minutes
|
|||
path: '/', |
|||
}).value = to.fullPath |
|||
|
|||
return navigateTo('/auth') |
|||
} |
|||
}) |
|||
@ -0,0 +1,219 @@ |
|||
<script setup lang="ts"> |
|||
/** |
|||
* Order Confirmation Page (/bestellung/bestaetigen/[orderId]) |
|||
* |
|||
* Features: |
|||
* - Requires authentication (middleware: auth) |
|||
* - Fetches order details from /api/orders/[orderId] |
|||
* - Validates order belongs to user (server-side) |
|||
* - Validates order status is 'pending' |
|||
* - Shows OrderSummary component |
|||
* - Shows billing address |
|||
* - Warning text before final confirmation |
|||
* - "Jetzt verbindlich bestellen" button |
|||
* - Redirects to success page after confirmation |
|||
*/ |
|||
|
|||
definePageMeta({ |
|||
middleware: 'auth', |
|||
layout: 'default', |
|||
}) |
|||
|
|||
const route = useRoute() |
|||
const orderId = computed(() => route.params.orderId as string) |
|||
|
|||
// Order data |
|||
const order = ref<any>(null) |
|||
const isLoading = ref(false) |
|||
const isConfirming = ref(false) |
|||
const error = ref<string | null>(null) |
|||
|
|||
// Fetch order details |
|||
async function fetchOrder() { |
|||
if (!orderId.value) return |
|||
|
|||
isLoading.value = true |
|||
error.value = null |
|||
|
|||
try { |
|||
order.value = await $fetch(`/api/orders/${orderId.value}`) |
|||
|
|||
// Check order status |
|||
if (order.value.status === 'completed') { |
|||
// Order already completed - redirect to success page |
|||
navigateTo(`/bestellung/erfolg/${orderId.value}`) |
|||
return |
|||
} |
|||
|
|||
if (order.value.status !== 'pending') { |
|||
error.value = `Bestellung kann nicht bestätigt werden. Status: ${order.value.status}` |
|||
} |
|||
} catch (err: any) { |
|||
console.error('Failed to fetch order:', err) |
|||
|
|||
if (err.statusCode === 404) { |
|||
error.value = 'Bestellung nicht gefunden' |
|||
} else if (err.statusCode === 403) { |
|||
error.value = 'Du hast keine Berechtigung, diese Bestellung zu sehen' |
|||
} else { |
|||
error.value = 'Fehler beim Laden der Bestellung' |
|||
} |
|||
|
|||
// Redirect to cart after 3 seconds |
|||
setTimeout(() => { |
|||
navigateTo('/warenkorb') |
|||
}, 3000) |
|||
} finally { |
|||
isLoading.value = false |
|||
} |
|||
} |
|||
|
|||
// Confirm order |
|||
async function confirmOrder() { |
|||
if (!orderId.value) return |
|||
|
|||
isConfirming.value = true |
|||
error.value = null |
|||
|
|||
try { |
|||
const response = await $fetch(`/api/orders/confirm/${orderId.value}`, { |
|||
method: 'POST', |
|||
}) |
|||
|
|||
if (response.success) { |
|||
// Redirect to success page |
|||
navigateTo(`/bestellung/erfolg/${orderId.value}`) |
|||
} |
|||
} catch (err: any) { |
|||
console.error('Failed to confirm order:', err) |
|||
error.value = |
|||
err.data?.message || |
|||
'Fehler beim Bestätigen der Bestellung. Bitte versuche es erneut.' |
|||
} finally { |
|||
isConfirming.value = false |
|||
} |
|||
} |
|||
|
|||
// Fetch order on mount |
|||
onMounted(() => { |
|||
fetchOrder() |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<div> |
|||
<CommonHeader /> |
|||
|
|||
<div class="container mx-auto px-4 py-8 max-w-4xl"> |
|||
<!-- Page Header --> |
|||
<div class="mb-8 text-center"> |
|||
<h1 class="text-4xl font-bold text-white mb-2">Bestellung bestätigen</h1> |
|||
<p class="text-white/70">Bitte überprüfe deine Bestellung vor der finalen Bestätigung</p> |
|||
</div> |
|||
|
|||
<!-- Error Alert --> |
|||
<Alert v-if="error" variant="destructive" class="mb-6"> |
|||
<AlertTitle>Fehler</AlertTitle> |
|||
<AlertDescription>{{ error }}</AlertDescription> |
|||
</Alert> |
|||
|
|||
<!-- Loading State --> |
|||
<div v-if="isLoading" class="text-center py-12"> |
|||
<div |
|||
class="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white mx-auto mb-4" |
|||
/> |
|||
<p class="text-white/60">Lade Bestellung...</p> |
|||
</div> |
|||
|
|||
<!-- Order Content --> |
|||
<div v-else-if="order" class="space-y-6"> |
|||
<!-- Order Summary Card --> |
|||
<Card class="p-6"> |
|||
<OrderSummary :order="order" :show-address="true" /> |
|||
</Card> |
|||
|
|||
<!-- Warning Notice --> |
|||
<Alert class="border-yellow-500/50 bg-yellow-500/10"> |
|||
<div class="flex items-start gap-3"> |
|||
<svg |
|||
class="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
viewBox="0 0 24 24" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
stroke-linecap="round" |
|||
stroke-linejoin="round" |
|||
stroke-width="2" |
|||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" |
|||
></path> |
|||
</svg> |
|||
<div> |
|||
<AlertTitle class="text-yellow-500">Wichtiger Hinweis</AlertTitle> |
|||
<AlertDescription class="text-yellow-100/90"> |
|||
Bitte überprüfe alle Angaben sorgfältig. Nach der Bestätigung ist die |
|||
Bestellung verbindlich und kann nicht mehr geändert werden. |
|||
</AlertDescription> |
|||
</div> |
|||
</div> |
|||
</Alert> |
|||
|
|||
<!-- Confirmation Button --> |
|||
<Card class="p-6"> |
|||
<div class="space-y-4"> |
|||
<Button |
|||
@click="confirmOrder" |
|||
:disabled="isConfirming" |
|||
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="!isConfirming" class="flex items-center justify-center gap-2"> |
|||
<svg |
|||
class="w-5 h-5" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
viewBox="0 0 24 24" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
stroke-linecap="round" |
|||
stroke-linejoin="round" |
|||
stroke-width="2" |
|||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" |
|||
></path> |
|||
</svg> |
|||
<span>Jetzt verbindlich bestellen</span> |
|||
</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" |
|||
/> |
|||
Bestätigung läuft... |
|||
</span> |
|||
</Button> |
|||
|
|||
<p class="text-xs text-white/60 text-center"> |
|||
Mit dem Klick auf "Jetzt verbindlich bestellen" akzeptierst du unsere |
|||
<NuxtLink to="/agb" class="text-experimenta-accent hover:underline"> |
|||
AGB |
|||
</NuxtLink> |
|||
und |
|||
<NuxtLink to="/datenschutz" class="text-experimenta-accent hover:underline"> |
|||
Datenschutzerklärung |
|||
</NuxtLink> |
|||
. |
|||
</p> |
|||
</div> |
|||
</Card> |
|||
|
|||
<!-- Back Link --> |
|||
<div class="text-center pt-4"> |
|||
<NuxtLink to="/warenkorb" class="text-sm text-experimenta-accent hover:underline"> |
|||
← Zurück zum Warenkorb |
|||
</NuxtLink> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,239 @@ |
|||
<script setup lang="ts"> |
|||
/** |
|||
* Order Success Page (/bestellung/erfolg/[orderId]) |
|||
* |
|||
* Features: |
|||
* - Requires authentication (middleware: auth) |
|||
* - Fetches order details from /api/orders/[orderId] |
|||
* - Validates order belongs to user (server-side) |
|||
* - Validates order status is 'completed' |
|||
* - Shows success message and animation |
|||
* - Shows order number |
|||
* - Shows OrderSummary component (read-only) |
|||
* - Links to homepage and product pages |
|||
*/ |
|||
|
|||
definePageMeta({ |
|||
middleware: 'auth', |
|||
layout: 'default', |
|||
}) |
|||
|
|||
const route = useRoute() |
|||
const orderId = computed(() => route.params.orderId as string) |
|||
|
|||
// Order data |
|||
const order = ref<any>(null) |
|||
const isLoading = ref(false) |
|||
const error = ref<string | null>(null) |
|||
|
|||
// Fetch order details |
|||
async function fetchOrder() { |
|||
if (!orderId.value) return |
|||
|
|||
isLoading.value = true |
|||
error.value = null |
|||
|
|||
try { |
|||
order.value = await $fetch(`/api/orders/${orderId.value}`) |
|||
|
|||
// Check order status |
|||
if (order.value.status !== 'completed') { |
|||
error.value = `Diese Bestellung wurde noch nicht abgeschlossen. Status: ${order.value.status}` |
|||
|
|||
// Redirect to confirmation page if still pending |
|||
if (order.value.status === 'pending') { |
|||
setTimeout(() => { |
|||
navigateTo(`/bestellung/bestaetigen/${orderId.value}`) |
|||
}, 2000) |
|||
} |
|||
} |
|||
} catch (err: any) { |
|||
console.error('Failed to fetch order:', err) |
|||
|
|||
if (err.statusCode === 404) { |
|||
error.value = 'Bestellung nicht gefunden' |
|||
} else if (err.statusCode === 403) { |
|||
error.value = 'Du hast keine Berechtigung, diese Bestellung zu sehen' |
|||
} else { |
|||
error.value = 'Fehler beim Laden der Bestellung' |
|||
} |
|||
|
|||
// Redirect to homepage after 3 seconds |
|||
setTimeout(() => { |
|||
navigateTo('/') |
|||
}, 3000) |
|||
} finally { |
|||
isLoading.value = false |
|||
} |
|||
} |
|||
|
|||
// Fetch order on mount |
|||
onMounted(() => { |
|||
fetchOrder() |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<div> |
|||
<CommonHeader /> |
|||
|
|||
<div class="container mx-auto px-4 py-8 max-w-4xl"> |
|||
<!-- Error Alert --> |
|||
<Alert v-if="error" variant="destructive" class="mb-6"> |
|||
<AlertTitle>Fehler</AlertTitle> |
|||
<AlertDescription>{{ error }}</AlertDescription> |
|||
</Alert> |
|||
|
|||
<!-- Loading State --> |
|||
<div v-if="isLoading" class="text-center py-12"> |
|||
<div |
|||
class="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white mx-auto mb-4" |
|||
/> |
|||
<p class="text-white/60">Lade Bestellung...</p> |
|||
</div> |
|||
|
|||
<!-- Success Content --> |
|||
<div v-else-if="order && order.status === 'completed'" class="space-y-8"> |
|||
<!-- Success Header with Animation --> |
|||
<div class="text-center space-y-4 py-8"> |
|||
<!-- Success Icon (animated checkmark) --> |
|||
<div class="flex justify-center mb-6"> |
|||
<div |
|||
class="rounded-full bg-green-500/20 p-6 border-4 border-green-500/50 animate-pulse" |
|||
> |
|||
<svg |
|||
class="w-16 h-16 text-green-500" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
viewBox="0 0 24 24" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
stroke-linecap="round" |
|||
stroke-linejoin="round" |
|||
stroke-width="2" |
|||
d="M5 13l4 4L19 7" |
|||
></path> |
|||
</svg> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Success Message --> |
|||
<h1 class="text-4xl font-bold text-white mb-2"> |
|||
Vielen Dank für deine Bestellung! |
|||
</h1> |
|||
<p class="text-xl text-white/70">Deine Bestellung wurde erfolgreich abgeschlossen.</p> |
|||
|
|||
<!-- Order Number --> |
|||
<div class="inline-block mt-4 px-6 py-3 bg-white/5 rounded-lg border border-white/10"> |
|||
<p class="text-sm text-white/60">Bestellnummer</p> |
|||
<p class="text-2xl font-mono font-bold text-experimenta-accent"> |
|||
{{ order.orderNumber }} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Next Steps Info --> |
|||
<Alert class="border-blue-500/50 bg-blue-500/10"> |
|||
<div class="flex items-start gap-3"> |
|||
<svg |
|||
class="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
viewBox="0 0 24 24" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
stroke-linecap="round" |
|||
stroke-linejoin="round" |
|||
stroke-width="2" |
|||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" |
|||
></path> |
|||
</svg> |
|||
<div> |
|||
<AlertTitle class="text-blue-400">Wie geht es weiter?</AlertTitle> |
|||
<AlertDescription class="text-blue-100/90 space-y-2"> |
|||
<p> |
|||
Du erhältst in Kürze eine Bestätigungs-E-Mail mit allen Details zu deiner |
|||
Bestellung. |
|||
</p> |
|||
<p> |
|||
Deine Makerspace-Jahreskarte wird bearbeitet und steht dir bald zur |
|||
Verfügung. |
|||
</p> |
|||
</AlertDescription> |
|||
</div> |
|||
</div> |
|||
</Alert> |
|||
|
|||
<!-- Order Summary Card --> |
|||
<Card class="p-6"> |
|||
<OrderSummary :order="order" :show-address="true" /> |
|||
</Card> |
|||
|
|||
<!-- Action Buttons --> |
|||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> |
|||
<NuxtLink to="/"> |
|||
<Button |
|||
variant="outline" |
|||
class="w-full border-white/20 hover:bg-white/10 text-white" |
|||
size="lg" |
|||
> |
|||
<svg |
|||
class="w-5 h-5 mr-2" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
viewBox="0 0 24 24" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
stroke-linecap="round" |
|||
stroke-linejoin="round" |
|||
stroke-width="2" |
|||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" |
|||
></path> |
|||
</svg> |
|||
Zurück zur Startseite |
|||
</Button> |
|||
</NuxtLink> |
|||
|
|||
<NuxtLink to="/experimenta"> |
|||
<Button |
|||
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" |
|||
> |
|||
<svg |
|||
class="w-5 h-5 mr-2" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
viewBox="0 0 24 24" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
stroke-linecap="round" |
|||
stroke-linejoin="round" |
|||
stroke-width="2" |
|||
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" |
|||
></path> |
|||
</svg> |
|||
Weitere Produkte kaufen |
|||
</Button> |
|||
</NuxtLink> |
|||
</div> |
|||
|
|||
<!-- Support Info --> |
|||
<div class="text-center pt-4 space-y-2"> |
|||
<p class="text-sm text-white/60"> |
|||
Fragen zu deiner Bestellung? Kontaktiere uns gerne: |
|||
</p> |
|||
<a |
|||
href="mailto:info@experimenta.science" |
|||
class="text-sm text-experimenta-accent hover:underline" |
|||
> |
|||
info@experimenta.science |
|||
</a> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,260 @@ |
|||
<script setup lang="ts"> |
|||
/** |
|||
* Checkout Page (/kasse) |
|||
* |
|||
* Features: |
|||
* - Requires authentication (middleware: auth) |
|||
* - Shows CheckoutForm with billing address |
|||
* - Shows CartSummary in right sidebar (desktop) / top (mobile) |
|||
* - Redirects to /warenkorb if cart is empty |
|||
* - Creates order on form submit |
|||
* - Redirects to /zahlung?orderId={orderId} after order creation |
|||
*/ |
|||
|
|||
// Type for checkout data (matches server schema) |
|||
type CheckoutData = { |
|||
salutation: 'male' | 'female' | 'other' |
|||
firstName: string |
|||
lastName: string |
|||
dateOfBirth: string |
|||
street: string |
|||
postCode: string |
|||
city: string |
|||
countryCode: string |
|||
saveAddress?: boolean |
|||
} |
|||
|
|||
definePageMeta({ |
|||
middleware: 'auth', |
|||
layout: 'default', |
|||
}) |
|||
|
|||
const { items, total, itemCount, fetchCart } = useCart() |
|||
|
|||
// Loading states |
|||
const isLoading = ref(true) // Start as true to prevent premature redirect |
|||
const isCreatingOrder = ref(false) |
|||
const cartLoaded = ref(false) // Track if initial cart fetch completed |
|||
|
|||
// Error state |
|||
const error = ref<string | null>(null) |
|||
|
|||
// Fetch cart on mount |
|||
onMounted(async () => { |
|||
isLoading.value = true |
|||
try { |
|||
await fetchCart() |
|||
cartLoaded.value = true // Mark cart as loaded |
|||
} catch (err) { |
|||
console.error('Failed to fetch cart:', err) |
|||
error.value = 'Fehler beim Laden des Warenkorbs' |
|||
cartLoaded.value = true // Mark as loaded even on error to allow redirect |
|||
} finally { |
|||
isLoading.value = false |
|||
} |
|||
}) |
|||
|
|||
// Redirect to homepage if cart is empty (only after cart is loaded) |
|||
watchEffect(() => { |
|||
if (cartLoaded.value && !isLoading.value && itemCount.value === 0) { |
|||
navigateTo('/') |
|||
} |
|||
}) |
|||
|
|||
// Handle checkout form submission |
|||
async function handleCheckout(checkoutData: CheckoutData) { |
|||
isCreatingOrder.value = true |
|||
error.value = null |
|||
|
|||
try { |
|||
// Create order via API |
|||
const response = await $fetch<{ |
|||
success: boolean |
|||
orderId: string |
|||
orderNumber: string |
|||
message: string |
|||
}>('/api/orders/create', { |
|||
method: 'POST', |
|||
body: checkoutData, |
|||
}) |
|||
|
|||
if (response.success) { |
|||
// Redirect to payment page with order ID |
|||
navigateTo(`/zahlung?orderId=${response.orderId}`) |
|||
} |
|||
} catch (err: any) { |
|||
console.error('Order creation failed:', err) |
|||
error.value = |
|||
err.data?.message || 'Fehler beim Erstellen der Bestellung. Bitte versuche es erneut.' |
|||
} finally { |
|||
isCreatingOrder.value = false |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div> |
|||
<CommonHeader /> |
|||
|
|||
<div class="container mx-auto px-4 py-8 max-w-7xl"> |
|||
<!-- Page Header --> |
|||
<div class="mb-8"> |
|||
<h1 class="text-4xl font-bold text-white mb-2">Zur Kasse</h1> |
|||
<p class="text-white/70"> |
|||
Bitte gib deine Rechnungsadresse ein, um die Bestellung abzuschließen. |
|||
</p> |
|||
</div> |
|||
|
|||
<!-- Error Alert --> |
|||
<Alert v-if="error" variant="destructive" class="mb-6"> |
|||
<AlertTitle>Fehler</AlertTitle> |
|||
<AlertDescription>{{ error }}</AlertDescription> |
|||
</Alert> |
|||
|
|||
<!-- Loading State --> |
|||
<div v-if="isLoading" class="text-center py-12"> |
|||
<div |
|||
class="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white mx-auto mb-4" |
|||
/> |
|||
<p class="text-white/60">Lade Warenkorb...</p> |
|||
</div> |
|||
|
|||
<!-- Checkout Layout --> |
|||
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-8"> |
|||
<!-- Left Column: Checkout Form (2/3 width on desktop) --> |
|||
<div class="lg:col-span-2"> |
|||
<Card class="p-6"> |
|||
<h2 class="text-2xl font-bold text-white mb-6">Rechnungsadresse</h2> |
|||
<CheckoutForm :loading="isCreatingOrder" @submit="handleCheckout" /> |
|||
</Card> |
|||
</div> |
|||
|
|||
<!-- Right Column: Cart Summary (1/3 width on desktop, sticky) --> |
|||
<div class="lg:col-span-1"> |
|||
<div class="lg:sticky lg:top-4"> |
|||
<!-- Mobile: Show summary at top, Desktop: Show in sidebar --> |
|||
<div class="lg:hidden mb-8"> |
|||
<CartSummary |
|||
:items="items" |
|||
:total="total" |
|||
:loading="isCreatingOrder" |
|||
@checkout="() => {}" |
|||
class="hidden" |
|||
/> |
|||
<!-- Simplified summary for mobile --> |
|||
<Card class="p-4 bg-white/5 border-white/10"> |
|||
<div class="flex items-center justify-between"> |
|||
<div> |
|||
<p class="text-sm text-white/60"> |
|||
{{ itemCount }} {{ itemCount === 1 ? 'Artikel' : 'Artikel' }} |
|||
</p> |
|||
<p class="text-2xl font-bold text-experimenta-accent"> |
|||
{{ |
|||
new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(total) |
|||
}} |
|||
</p> |
|||
</div> |
|||
<NuxtLink |
|||
to="/warenkorb" |
|||
class="text-sm text-experimenta-accent hover:underline" |
|||
> |
|||
Bearbeiten |
|||
</NuxtLink> |
|||
</div> |
|||
</Card> |
|||
</div> |
|||
|
|||
<!-- Desktop: Full cart summary --> |
|||
<div class="hidden lg:block"> |
|||
<Card class="p-6"> |
|||
<h2 class="text-xl font-bold text-white mb-4">Deine Bestellung</h2> |
|||
|
|||
<!-- Items List --> |
|||
<div class="space-y-3 mb-6"> |
|||
<div |
|||
v-for="item in items" |
|||
:key="item.id" |
|||
class="flex items-start justify-between gap-3 pb-3 border-b border-white/10" |
|||
> |
|||
<div class="flex-1"> |
|||
<p class="text-sm font-medium text-white">{{ item.product.name }}</p> |
|||
<p class="text-xs text-white/60"> |
|||
{{ item.quantity }}x |
|||
{{ |
|||
new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(Number.parseFloat(item.product.price)) |
|||
}} |
|||
</p> |
|||
</div> |
|||
<p class="text-sm font-semibold text-white"> |
|||
{{ |
|||
new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(item.subtotal) |
|||
}} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Totals --> |
|||
<div class="space-y-2 pt-4 border-t border-white/20"> |
|||
<div class="flex items-center justify-between text-white/80"> |
|||
<span class="text-sm">Zwischensumme</span> |
|||
<span class="font-medium"> |
|||
{{ |
|||
new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(total) |
|||
}} |
|||
</span> |
|||
</div> |
|||
<div class="flex items-center justify-between text-white/60 text-sm"> |
|||
<span>inkl. MwSt. (7%)</span> |
|||
<span> |
|||
{{ |
|||
new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(total * (0.07 / 1.07)) |
|||
}} |
|||
</span> |
|||
</div> |
|||
<div |
|||
class="flex items-center justify-between pt-3 border-t border-white/20" |
|||
> |
|||
<span class="text-lg font-bold text-white">Gesamt</span> |
|||
<span class="text-2xl font-bold text-experimenta-accent"> |
|||
{{ |
|||
new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(total) |
|||
}} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Edit Cart Link --> |
|||
<div class="mt-6 pt-6 border-t border-white/20"> |
|||
<NuxtLink |
|||
to="/warenkorb" |
|||
class="text-sm text-experimenta-accent hover:underline" |
|||
> |
|||
Warenkorb bearbeiten |
|||
</NuxtLink> |
|||
</div> |
|||
</Card> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,186 @@ |
|||
<script setup lang="ts"> |
|||
/** |
|||
* Payment Mock Page (/zahlung) |
|||
* |
|||
* Features: |
|||
* - Requires authentication (middleware: auth) |
|||
* - Shows order total |
|||
* - Shows MockPayPalButton component |
|||
* - Redirects to order confirmation after "payment" success |
|||
* - Query param: orderId (required) |
|||
*/ |
|||
|
|||
definePageMeta({ |
|||
middleware: 'auth', |
|||
layout: 'default', |
|||
}) |
|||
|
|||
const route = useRoute() |
|||
|
|||
// Get order ID from query params |
|||
const orderId = computed(() => route.query.orderId as string | undefined) |
|||
|
|||
// Order data |
|||
const order = ref<any>(null) |
|||
const isLoading = ref(false) |
|||
const error = ref<string | null>(null) |
|||
|
|||
// Redirect to cart if no order ID |
|||
watchEffect(() => { |
|||
if (!orderId.value) { |
|||
navigateTo('/warenkorb') |
|||
} |
|||
}) |
|||
|
|||
// Fetch order details |
|||
onMounted(async () => { |
|||
if (!orderId.value) return |
|||
|
|||
isLoading.value = true |
|||
error.value = null |
|||
|
|||
try { |
|||
order.value = await $fetch(`/api/orders/${orderId.value}`) |
|||
} catch (err: any) { |
|||
console.error('Failed to fetch order:', err) |
|||
error.value = 'Bestellung nicht gefunden' |
|||
|
|||
// Redirect to cart after 3 seconds |
|||
setTimeout(() => { |
|||
navigateTo('/warenkorb') |
|||
}, 3000) |
|||
} finally { |
|||
isLoading.value = false |
|||
} |
|||
}) |
|||
|
|||
// Handle payment success |
|||
function handlePaymentSuccess() { |
|||
if (!orderId.value) return |
|||
|
|||
// Redirect to order confirmation page |
|||
navigateTo(`/bestellung/bestaetigen/${orderId.value}`) |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div> |
|||
<CommonHeader /> |
|||
|
|||
<div class="container mx-auto px-4 py-8 max-w-2xl"> |
|||
<!-- Page Header --> |
|||
<div class="mb-8 text-center"> |
|||
<h1 class="text-4xl font-bold text-white mb-2">Zahlung</h1> |
|||
<p class="text-white/70">Schließe deine Bestellung mit PayPal ab</p> |
|||
</div> |
|||
|
|||
<!-- Error Alert --> |
|||
<Alert v-if="error" variant="destructive" class="mb-6"> |
|||
<AlertTitle>Fehler</AlertTitle> |
|||
<AlertDescription> |
|||
{{ error }}. Du wirst zum Warenkorb weitergeleitet... |
|||
</AlertDescription> |
|||
</Alert> |
|||
|
|||
<!-- Loading State --> |
|||
<div v-if="isLoading" class="text-center py-12"> |
|||
<div |
|||
class="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white mx-auto mb-4" |
|||
/> |
|||
<p class="text-white/60">Lade Bestellung...</p> |
|||
</div> |
|||
|
|||
<!-- Payment Content --> |
|||
<div v-else-if="order" class="space-y-6"> |
|||
<!-- Order Summary Card --> |
|||
<Card class="p-6"> |
|||
<div class="space-y-4"> |
|||
<div class="flex items-center justify-between pb-4 border-b border-white/20"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-white">Bestellnummer</h2> |
|||
<p class="text-sm font-mono text-white/80">{{ order.orderNumber }}</p> |
|||
</div> |
|||
<div class="text-right"> |
|||
<p class="text-sm text-white/60">Zu zahlen</p> |
|||
<p class="text-2xl font-bold text-experimenta-accent"> |
|||
{{ |
|||
new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(Number.parseFloat(order.totalAmount)) |
|||
}} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Order Items Summary --> |
|||
<div class="space-y-2"> |
|||
<h3 class="text-sm font-medium text-white/80">Artikel in dieser Bestellung:</h3> |
|||
<div class="space-y-1"> |
|||
<div |
|||
v-for="item in order.items" |
|||
:key="item.id" |
|||
class="flex items-center justify-between text-sm" |
|||
> |
|||
<span class="text-white/70"> |
|||
{{ item.quantity }}x |
|||
{{ item.productSnapshot?.name || item.product?.name }} |
|||
</span> |
|||
<span class="text-white/80 font-medium"> |
|||
{{ |
|||
new Intl.NumberFormat('de-DE', { |
|||
style: 'currency', |
|||
currency: 'EUR', |
|||
}).format(item.subtotal) |
|||
}} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</Card> |
|||
|
|||
<!-- Payment Button Card --> |
|||
<Card class="p-6"> |
|||
<h2 class="text-lg font-semibold text-white mb-4">Zahlungsmethode</h2> |
|||
<MockPayPalButton |
|||
:order-id="orderId!" |
|||
:amount="Number.parseFloat(order.totalAmount)" |
|||
@success="handlePaymentSuccess" |
|||
/> |
|||
</Card> |
|||
|
|||
<!-- Security Note --> |
|||
<div class="text-center space-y-2 pt-4"> |
|||
<div class="flex items-center justify-center gap-2 text-sm text-white/60"> |
|||
<svg |
|||
class="w-4 h-4" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
viewBox="0 0 24 24" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
stroke-linecap="round" |
|||
stroke-linejoin="round" |
|||
stroke-width="2" |
|||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" |
|||
></path> |
|||
</svg> |
|||
<span>Sichere Verbindung</span> |
|||
</div> |
|||
<p class="text-xs text-white/40"> |
|||
Deine Zahlung wird über eine sichere SSL-Verbindung verarbeitet. |
|||
</p> |
|||
</div> |
|||
|
|||
<!-- Back Link --> |
|||
<div class="text-center pt-4"> |
|||
<NuxtLink to="/kasse" class="text-sm text-experimenta-accent hover:underline"> |
|||
← Zurück zur Kasse |
|||
</NuxtLink> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,295 @@ |
|||
/** |
|||
* Date Formatting Utilities |
|||
* |
|||
* Provides utilities for formatting dates in German locale and converting |
|||
* between different date formats for display and database storage. |
|||
*/ |
|||
|
|||
/** |
|||
* Format a date to German format (DD.MM.YYYY) |
|||
* |
|||
* @param date - Date to format (Date object, ISO string, or timestamp) |
|||
* @returns Formatted date string (e.g., "24.12.2024") |
|||
* |
|||
* @example |
|||
* formatDateGerman(new Date('2024-12-24')) |
|||
* // => "24.12.2024"
|
|||
* |
|||
* formatDateGerman('2024-12-24T10:30:00Z') |
|||
* // => "24.12.2024"
|
|||
*/ |
|||
export function formatDateGerman(date: Date | string | number): string { |
|||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date |
|||
|
|||
if (isNaN(dateObj.getTime())) { |
|||
return '' |
|||
} |
|||
|
|||
return dateObj.toLocaleDateString('de-DE', { |
|||
day: '2-digit', |
|||
month: '2-digit', |
|||
year: 'numeric', |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Format a date to German long format (e.g., "24. Dezember 2024") |
|||
* |
|||
* @param date - Date to format |
|||
* @returns Formatted date string with month name |
|||
* |
|||
* @example |
|||
* formatDateGermanLong(new Date('2024-12-24')) |
|||
* // => "24. Dezember 2024"
|
|||
*/ |
|||
export function formatDateGermanLong(date: Date | string | number): string { |
|||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date |
|||
|
|||
if (isNaN(dateObj.getTime())) { |
|||
return '' |
|||
} |
|||
|
|||
return dateObj.toLocaleDateString('de-DE', { |
|||
day: 'numeric', |
|||
month: 'long', |
|||
year: 'numeric', |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Format a date with time to German format (DD.MM.YYYY HH:MM) |
|||
* |
|||
* @param date - Date to format |
|||
* @returns Formatted datetime string |
|||
* |
|||
* @example |
|||
* formatDateTimeGerman(new Date('2024-12-24T15:30:00Z')) |
|||
* // => "24.12.2024 15:30"
|
|||
*/ |
|||
export function formatDateTimeGerman(date: Date | string | number): string { |
|||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date |
|||
|
|||
if (isNaN(dateObj.getTime())) { |
|||
return '' |
|||
} |
|||
|
|||
return dateObj.toLocaleString('de-DE', { |
|||
day: '2-digit', |
|||
month: '2-digit', |
|||
year: 'numeric', |
|||
hour: '2-digit', |
|||
minute: '2-digit', |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Parse a date input string (DD.MM.YYYY or YYYY-MM-DD) to ISO format (YYYY-MM-DD) |
|||
* Suitable for database storage and HTML date inputs |
|||
* |
|||
* @param dateString - Date string in DD.MM.YYYY or YYYY-MM-DD format |
|||
* @returns ISO date string (YYYY-MM-DD) or null if invalid |
|||
* |
|||
* @example |
|||
* parseDateToISO('24.12.2024') |
|||
* // => "2024-12-24"
|
|||
* |
|||
* parseDateToISO('2024-12-24') |
|||
* // => "2024-12-24"
|
|||
* |
|||
* parseDateToISO('invalid') |
|||
* // => null
|
|||
*/ |
|||
export function parseDateToISO(dateString: string): string | null { |
|||
if (!dateString || dateString.trim() === '') { |
|||
return null |
|||
} |
|||
|
|||
// Check if already in ISO format (YYYY-MM-DD)
|
|||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { |
|||
const date = new Date(dateString) |
|||
return isNaN(date.getTime()) ? null : dateString |
|||
} |
|||
|
|||
// Parse German format (DD.MM.YYYY)
|
|||
const germanMatch = dateString.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/) |
|||
if (germanMatch) { |
|||
const [, day, month, year] = germanMatch |
|||
const paddedDay = day.padStart(2, '0') |
|||
const paddedMonth = month.padStart(2, '0') |
|||
const isoDate = `${year}-${paddedMonth}-${paddedDay}` |
|||
|
|||
// Validate the date
|
|||
const date = new Date(isoDate) |
|||
if (isNaN(date.getTime())) { |
|||
return null |
|||
} |
|||
|
|||
return isoDate |
|||
} |
|||
|
|||
return null |
|||
} |
|||
|
|||
/** |
|||
* Convert ISO date string (YYYY-MM-DD) to German format (DD.MM.YYYY) |
|||
* |
|||
* @param isoDate - ISO date string (YYYY-MM-DD) |
|||
* @returns German date string (DD.MM.YYYY) or empty string if invalid |
|||
* |
|||
* @example |
|||
* isoToGermanDate('2024-12-24') |
|||
* // => "24.12.2024"
|
|||
*/ |
|||
export function isoToGermanDate(isoDate: string): string { |
|||
if (!isoDate || !/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) { |
|||
return '' |
|||
} |
|||
|
|||
const [year, month, day] = isoDate.split('-') |
|||
return `${day}.${month}.${year}` |
|||
} |
|||
|
|||
/** |
|||
* Validate if a date of birth indicates a person is at least 18 years old |
|||
* |
|||
* @param dateOfBirth - Date of birth (Date object, ISO string, or German format) |
|||
* @returns true if person is 18 or older |
|||
* |
|||
* @example |
|||
* isAgeAtLeast18(new Date('2000-01-01')) |
|||
* // => true (as of 2024)
|
|||
* |
|||
* isAgeAtLeast18('01.01.2010') |
|||
* // => false (as of 2024)
|
|||
*/ |
|||
export function isAgeAtLeast18(dateOfBirth: Date | string): boolean { |
|||
let dateObj: Date |
|||
|
|||
if (typeof dateOfBirth === 'string') { |
|||
// Try parsing German format first
|
|||
const isoDate = parseDateToISO(dateOfBirth) |
|||
if (isoDate) { |
|||
dateObj = new Date(isoDate) |
|||
} else { |
|||
dateObj = new Date(dateOfBirth) |
|||
} |
|||
} else { |
|||
dateObj = dateOfBirth |
|||
} |
|||
|
|||
if (isNaN(dateObj.getTime())) { |
|||
return false |
|||
} |
|||
|
|||
const today = new Date() |
|||
const age = today.getFullYear() - dateObj.getFullYear() |
|||
const monthDiff = today.getMonth() - dateObj.getMonth() |
|||
const dayDiff = today.getDate() - dateObj.getDate() |
|||
|
|||
// Check if birthday has occurred this year
|
|||
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) { |
|||
return age - 1 >= 18 |
|||
} |
|||
|
|||
return age >= 18 |
|||
} |
|||
|
|||
/** |
|||
* Format a relative date (e.g., "vor 2 Tagen", "gerade eben") |
|||
* |
|||
* @param date - Date to format |
|||
* @returns Relative time string in German |
|||
* |
|||
* @example |
|||
* formatRelativeDate(new Date(Date.now() - 1000 * 60 * 5)) |
|||
* // => "vor 5 Minuten"
|
|||
*/ |
|||
export function formatRelativeDate(date: Date | string | number): string { |
|||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date |
|||
|
|||
if (isNaN(dateObj.getTime())) { |
|||
return '' |
|||
} |
|||
|
|||
const now = new Date() |
|||
const diffMs = now.getTime() - dateObj.getTime() |
|||
const diffSecs = Math.floor(diffMs / 1000) |
|||
const diffMins = Math.floor(diffSecs / 60) |
|||
const diffHours = Math.floor(diffMins / 60) |
|||
const diffDays = Math.floor(diffHours / 24) |
|||
|
|||
if (diffSecs < 60) { |
|||
return 'gerade eben' |
|||
} |
|||
|
|||
if (diffMins < 60) { |
|||
return `vor ${diffMins} ${diffMins === 1 ? 'Minute' : 'Minuten'}` |
|||
} |
|||
|
|||
if (diffHours < 24) { |
|||
return `vor ${diffHours} ${diffHours === 1 ? 'Stunde' : 'Stunden'}` |
|||
} |
|||
|
|||
if (diffDays < 7) { |
|||
return `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}` |
|||
} |
|||
|
|||
if (diffDays < 30) { |
|||
const weeks = Math.floor(diffDays / 7) |
|||
return `vor ${weeks} ${weeks === 1 ? 'Woche' : 'Wochen'}` |
|||
} |
|||
|
|||
if (diffDays < 365) { |
|||
const months = Math.floor(diffDays / 30) |
|||
return `vor ${months} ${months === 1 ? 'Monat' : 'Monaten'}` |
|||
} |
|||
|
|||
const years = Math.floor(diffDays / 365) |
|||
return `vor ${years} ${years === 1 ? 'Jahr' : 'Jahren'}` |
|||
} |
|||
|
|||
/** |
|||
* Get today's date in ISO format (YYYY-MM-DD) |
|||
* |
|||
* @returns Today's date in ISO format |
|||
* |
|||
* @example |
|||
* getTodayISO() |
|||
* // => "2024-12-24"
|
|||
*/ |
|||
export function getTodayISO(): string { |
|||
const today = new Date() |
|||
return today.toISOString().split('T')[0] |
|||
} |
|||
|
|||
/** |
|||
* Validate if a date is in the future |
|||
* |
|||
* @param date - Date to validate |
|||
* @returns true if date is in the future |
|||
*/ |
|||
export function isDateInFuture(date: Date | string): boolean { |
|||
const dateObj = typeof date === 'string' ? new Date(date) : date |
|||
|
|||
if (isNaN(dateObj.getTime())) { |
|||
return false |
|||
} |
|||
|
|||
return dateObj.getTime() > Date.now() |
|||
} |
|||
|
|||
/** |
|||
* Validate if a date is in the past |
|||
* |
|||
* @param date - Date to validate |
|||
* @returns true if date is in the past |
|||
*/ |
|||
export function isDateInPast(date: Date | string): boolean { |
|||
const dateObj = typeof date === 'string' ? new Date(date) : date |
|||
|
|||
if (isNaN(dateObj.getTime())) { |
|||
return false |
|||
} |
|||
|
|||
return dateObj.getTime() < Date.now() |
|||
} |
|||
@ -0,0 +1,262 @@ |
|||
/** |
|||
* Error Message Utilities |
|||
* |
|||
* Provides German error messages for form validation and API errors. |
|||
* Uses informal "Du" form as per project requirements. |
|||
*/ |
|||
|
|||
/** |
|||
* Common validation error messages (German) |
|||
*/ |
|||
export const validationMessages = { |
|||
required: 'Dieses Feld ist erforderlich', |
|||
email: 'Bitte gib eine gültige E-Mail-Adresse ein', |
|||
minLength: (min: number) => `Mindestens ${min} Zeichen erforderlich`, |
|||
maxLength: (max: number) => `Maximal ${max} Zeichen erlaubt`, |
|||
min: (min: number) => `Wert muss mindestens ${min} sein`, |
|||
max: (max: number) => `Wert darf maximal ${max} sein`, |
|||
pattern: 'Ungültiges Format', |
|||
url: 'Bitte gib eine gültige URL ein', |
|||
numeric: 'Nur Zahlen erlaubt', |
|||
phoneNumber: 'Bitte gib eine gültige Telefonnummer ein', |
|||
postCode: 'Bitte gib eine gültige Postleitzahl ein', |
|||
dateInvalid: 'Ungültiges Datum', |
|||
dateFuture: 'Datum darf nicht in der Zukunft liegen', |
|||
datePast: 'Datum darf nicht in der Vergangenheit liegen', |
|||
dateOfBirthTooYoung: 'Du musst mindestens 18 Jahre alt sein', |
|||
passwordWeak: |
|||
'Passwort muss mindestens 8 Zeichen, einen Großbuchstaben, einen Kleinbuchstaben und eine Zahl enthalten', |
|||
passwordMismatch: 'Passwörter stimmen nicht überein', |
|||
terms: 'Bitte akzeptiere die AGB', |
|||
privacy: 'Bitte akzeptiere die Datenschutzerklärung', |
|||
} |
|||
|
|||
/** |
|||
* Field-specific error messages |
|||
*/ |
|||
export const fieldMessages = { |
|||
salutation: { |
|||
required: 'Bitte wähle eine Anrede', |
|||
}, |
|||
firstName: { |
|||
required: 'Bitte gib deinen Vornamen ein', |
|||
minLength: 'Vorname muss mindestens 2 Zeichen lang sein', |
|||
}, |
|||
lastName: { |
|||
required: 'Bitte gib deinen Nachnamen ein', |
|||
minLength: 'Nachname muss mindestens 2 Zeichen lang sein', |
|||
}, |
|||
email: { |
|||
required: 'Bitte gib deine E-Mail-Adresse ein', |
|||
invalid: 'Bitte gib eine gültige E-Mail-Adresse ein', |
|||
}, |
|||
password: { |
|||
required: 'Bitte gib ein Passwort ein', |
|||
weak: |
|||
'Passwort muss mindestens 8 Zeichen, einen Großbuchstaben, einen Kleinbuchstaben und eine Zahl enthalten', |
|||
}, |
|||
dateOfBirth: { |
|||
required: 'Bitte gib dein Geburtsdatum ein', |
|||
invalid: 'Ungültiges Datum', |
|||
tooYoung: 'Du musst mindestens 18 Jahre alt sein', |
|||
future: 'Geburtsdatum darf nicht in der Zukunft liegen', |
|||
}, |
|||
street: { |
|||
required: 'Bitte gib deine Straße und Hausnummer ein', |
|||
minLength: 'Straße muss mindestens 3 Zeichen lang sein', |
|||
}, |
|||
postCode: { |
|||
required: 'Bitte gib deine Postleitzahl ein', |
|||
invalid: 'Ungültige Postleitzahl', |
|||
}, |
|||
city: { |
|||
required: 'Bitte gib deinen Ort ein', |
|||
minLength: 'Ort muss mindestens 2 Zeichen lang sein', |
|||
}, |
|||
countryCode: { |
|||
required: 'Bitte wähle ein Land', |
|||
}, |
|||
phone: { |
|||
invalid: 'Bitte gib eine gültige Telefonnummer ein', |
|||
}, |
|||
quantity: { |
|||
min: 'Menge muss mindestens 1 sein', |
|||
max: (max: number) => `Maximal ${max} Stück verfügbar`, |
|||
}, |
|||
} |
|||
|
|||
/** |
|||
* API error messages |
|||
*/ |
|||
export const apiErrorMessages = { |
|||
// Authentication errors
|
|||
auth: { |
|||
invalidCredentials: 'E-Mail oder Passwort ist falsch', |
|||
emailAlreadyExists: 'Diese E-Mail-Adresse wird bereits verwendet', |
|||
sessionExpired: 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an', |
|||
unauthorized: 'Du musst angemeldet sein, um diese Aktion durchzuführen', |
|||
forbidden: 'Du hast keine Berechtigung für diese Aktion', |
|||
}, |
|||
// Product errors
|
|||
products: { |
|||
notFound: 'Produkt nicht gefunden', |
|||
unavailable: 'Produkt ist nicht verfügbar', |
|||
outOfStock: 'Produkt ist nicht mehr auf Lager', |
|||
insufficientStock: 'Nicht genügend auf Lager', |
|||
}, |
|||
// Cart errors
|
|||
cart: { |
|||
notFound: 'Warenkorb nicht gefunden', |
|||
itemNotFound: 'Artikel nicht im Warenkorb gefunden', |
|||
empty: 'Dein Warenkorb ist leer', |
|||
invalidQuantity: 'Ungültige Menge', |
|||
addFailed: 'Artikel konnte nicht hinzugefügt werden', |
|||
updateFailed: 'Artikel konnte nicht aktualisiert werden', |
|||
removeFailed: 'Artikel konnte nicht entfernt werden', |
|||
}, |
|||
// Order errors
|
|||
orders: { |
|||
notFound: 'Bestellung nicht gefunden', |
|||
createFailed: 'Bestellung konnte nicht erstellt werden', |
|||
paymentFailed: 'Zahlung fehlgeschlagen', |
|||
invalidAddress: 'Ungültige Lieferadresse', |
|||
submissionFailed: 'Bestellung konnte nicht übermittelt werden', |
|||
}, |
|||
// Generic errors
|
|||
generic: { |
|||
networkError: 'Netzwerkfehler. Bitte überprüfe deine Internetverbindung', |
|||
serverError: 'Ein Serverfehler ist aufgetreten. Bitte versuche es später erneut', |
|||
validationError: 'Bitte überprüfe deine Eingaben', |
|||
unknownError: 'Ein unbekannter Fehler ist aufgetreten', |
|||
}, |
|||
} |
|||
|
|||
/** |
|||
* Format API error response to user-friendly message |
|||
* |
|||
* @param error - Error object from API call |
|||
* @returns User-friendly error message in German |
|||
* |
|||
* @example |
|||
* try { |
|||
* await $fetch('/api/cart/items', { method: 'POST', body: { productId, quantity } }) |
|||
* } catch (error) { |
|||
* const message = formatApiError(error) |
|||
* toast.error(message) |
|||
* } |
|||
*/ |
|||
export function formatApiError(error: unknown): string { |
|||
// Handle FetchError from Nuxt $fetch
|
|||
if (error && typeof error === 'object' && 'statusCode' in error) { |
|||
const statusCode = (error as { statusCode: number }).statusCode |
|||
const data = (error as { data?: { message?: string } }).data |
|||
|
|||
// Use custom message from API if available
|
|||
if (data?.message) { |
|||
return data.message |
|||
} |
|||
|
|||
// Map status codes to generic messages
|
|||
switch (statusCode) { |
|||
case 400: |
|||
return apiErrorMessages.generic.validationError |
|||
case 401: |
|||
return apiErrorMessages.auth.unauthorized |
|||
case 403: |
|||
return apiErrorMessages.auth.forbidden |
|||
case 404: |
|||
return 'Ressource nicht gefunden' |
|||
case 409: |
|||
return 'Konflikt: Diese Aktion kann nicht durchgeführt werden' |
|||
case 422: |
|||
return apiErrorMessages.generic.validationError |
|||
case 429: |
|||
return 'Zu viele Anfragen. Bitte warte einen Moment' |
|||
case 500: |
|||
return apiErrorMessages.generic.serverError |
|||
case 503: |
|||
return 'Service vorübergehend nicht verfügbar' |
|||
default: |
|||
return apiErrorMessages.generic.unknownError |
|||
} |
|||
} |
|||
|
|||
// Handle network errors
|
|||
if (error instanceof TypeError && error.message.includes('fetch')) { |
|||
return apiErrorMessages.generic.networkError |
|||
} |
|||
|
|||
// Handle Error objects with message
|
|||
if (error instanceof Error) { |
|||
return error.message |
|||
} |
|||
|
|||
// Fallback for unknown error types
|
|||
return apiErrorMessages.generic.unknownError |
|||
} |
|||
|
|||
/** |
|||
* Get validation error message for a specific field and error type |
|||
* |
|||
* @param field - Field name |
|||
* @param errorType - Zod error type (e.g., 'required', 'invalid_string', 'too_small') |
|||
* @param params - Additional parameters (e.g., min, max values) |
|||
* @returns User-friendly validation error message |
|||
* |
|||
* @example |
|||
* const message = getValidationMessage('email', 'invalid_string') |
|||
* // => "Bitte gib eine gültige E-Mail-Adresse ein"
|
|||
*/ |
|||
export function getValidationMessage( |
|||
field: string, |
|||
errorType: string, |
|||
params?: { minimum?: number; maximum?: number } |
|||
): string { |
|||
// Check field-specific messages first
|
|||
const fieldKey = field as keyof typeof fieldMessages |
|||
if (fieldKey in fieldMessages) { |
|||
const fieldMessage = fieldMessages[fieldKey] |
|||
|
|||
// Map Zod error types to field-specific messages
|
|||
if (errorType === 'invalid_type' || errorType === 'required') { |
|||
return fieldMessage.required || validationMessages.required |
|||
} |
|||
|
|||
if (errorType === 'invalid_string' || errorType === 'invalid_email') { |
|||
return 'invalid' in fieldMessage |
|||
? fieldMessage.invalid |
|||
: validationMessages.email |
|||
} |
|||
|
|||
if (errorType === 'too_small' && 'minLength' in fieldMessage) { |
|||
return fieldMessage.minLength |
|||
} |
|||
|
|||
if (errorType === 'too_small' && params?.minimum) { |
|||
return validationMessages.minLength(params.minimum) |
|||
} |
|||
|
|||
if (errorType === 'too_big' && params?.maximum) { |
|||
return validationMessages.maxLength(params.maximum) |
|||
} |
|||
} |
|||
|
|||
// Fallback to generic messages
|
|||
if (errorType === 'invalid_type' || errorType === 'required') { |
|||
return validationMessages.required |
|||
} |
|||
|
|||
if (errorType === 'invalid_string' || errorType === 'invalid_email') { |
|||
return validationMessages.email |
|||
} |
|||
|
|||
if (errorType === 'too_small' && params?.minimum) { |
|||
return validationMessages.minLength(params.minimum) |
|||
} |
|||
|
|||
if (errorType === 'too_big' && params?.maximum) { |
|||
return validationMessages.maxLength(params.maximum) |
|||
} |
|||
|
|||
return validationMessages.required |
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
/** |
|||
* Add product to cart for testing |
|||
*/ |
|||
import { drizzle } from 'drizzle-orm/postgres-js' |
|||
import postgres from 'postgres' |
|||
import * as schema from '../server/database/schema' |
|||
import { eq } from 'drizzle-orm' |
|||
|
|||
const { products, carts, cartItems, users } = schema |
|||
|
|||
const connectionString = process.env.DATABASE_URL || 'postgresql://dev:dev_password_change_me@localhost:5432/experimenta_dev' |
|||
const client = postgres(connectionString) |
|||
const db = drizzle(client, { schema }) |
|||
|
|||
async function addToCart() { |
|||
console.log('🛒 Adding products to cart for test user...') |
|||
|
|||
// Get any user (the first one)
|
|||
const user = await db.query.users.findFirst() |
|||
|
|||
if (!user) { |
|||
console.error('❌ No users found. Please login first.') |
|||
await client.end() |
|||
process.exit(1) |
|||
} |
|||
|
|||
console.log(` ✓ Found user: ${user.email} (ID: ${user.id})`) |
|||
|
|||
// Get or create cart
|
|||
let cart = await db.query.carts.findFirst({ |
|||
where: eq(carts.userId, user.id), |
|||
}) |
|||
|
|||
if (!cart) { |
|||
const [newCart] = await db.insert(carts).values({ userId: user.id }).returning() |
|||
cart = newCart |
|||
console.log(` ✓ Created cart: ${cart.id}`) |
|||
} else { |
|||
console.log(` ✓ Found cart: ${cart.id}`) |
|||
} |
|||
|
|||
// Get first product
|
|||
const product = await db.query.products.findFirst({ |
|||
where: eq(products.active, true), |
|||
}) |
|||
|
|||
if (!product) { |
|||
console.error('❌ No active products found.') |
|||
await client.end() |
|||
process.exit(1) |
|||
} |
|||
|
|||
console.log(` ✓ Found product: ${product.name} (${product.price}€)`) |
|||
|
|||
// Check if item already exists
|
|||
const existingItem = await db.query.cartItems.findFirst({ |
|||
where: (item, { and, eq }) => and(eq(item.cartId, cart.id), eq(item.productId, product.id)), |
|||
}) |
|||
|
|||
if (existingItem) { |
|||
console.log(` ℹ Product already in cart, skipping...`) |
|||
console.log(`✅ Cart ready for checkout!`) |
|||
await client.end() |
|||
return |
|||
} |
|||
|
|||
// Add to cart
|
|||
const [cartItem] = await db |
|||
.insert(cartItems) |
|||
.values({ |
|||
cartId: cart.id, |
|||
productId: product.id, |
|||
quantity: 2, |
|||
}) |
|||
.returning() |
|||
|
|||
console.log(` ✓ Added ${cartItem.quantity}x "${product.name}" to cart`) |
|||
console.log(`✅ Cart ready for checkout!`) |
|||
|
|||
await client.end() |
|||
} |
|||
|
|||
addToCart().catch((error) => { |
|||
console.error('❌ Error:', error) |
|||
process.exit(1) |
|||
}) |
|||
@ -0,0 +1,98 @@ |
|||
/** |
|||
* Seed test products for checkout testing |
|||
*/ |
|||
import { drizzle } from 'drizzle-orm/postgres-js' |
|||
import postgres from 'postgres' |
|||
import { products, productRoleVisibility } from '../server/database/schema' |
|||
|
|||
const connectionString = process.env.DATABASE_URL || 'postgresql://dev:dev_password_change_me@localhost:5432/experimenta_dev' |
|||
const client = postgres(connectionString) |
|||
const db = drizzle(client, { logger: false }) |
|||
|
|||
async function seedProducts() { |
|||
console.log('🌱 Seeding test products...') |
|||
|
|||
// Insert test products
|
|||
const testProducts = [ |
|||
{ |
|||
navProductId: 'MAK-001', |
|||
name: 'Makerspace Jahreskarte Erwachsene', |
|||
description: 'Jahrespass für den Makerspace - für Erwachsene ab 18 Jahren', |
|||
price: '89.00', |
|||
category: 'makerspace-annual-pass', |
|||
active: true, |
|||
stockQuantity: 100, |
|||
}, |
|||
{ |
|||
navProductId: 'MAK-002', |
|||
name: 'Makerspace Jahreskarte Kinder', |
|||
description: 'Jahrespass für den Makerspace - für Kinder und Jugendliche', |
|||
price: '49.00', |
|||
category: 'makerspace-annual-pass', |
|||
active: true, |
|||
stockQuantity: 100, |
|||
}, |
|||
{ |
|||
navProductId: 'EXP-001', |
|||
name: 'experimenta Jahreskarte', |
|||
description: 'Jahrespass für die experimenta - freier Eintritt für ein Jahr', |
|||
price: '129.00', |
|||
category: 'annual-pass', |
|||
active: true, |
|||
stockQuantity: 50, |
|||
}, |
|||
] |
|||
|
|||
for (const product of testProducts) { |
|||
console.log(` Adding product: ${product.name}`) |
|||
|
|||
// Insert product
|
|||
const [insertedProduct] = await db |
|||
.insert(products) |
|||
.values(product) |
|||
.onConflictDoUpdate({ |
|||
target: products.navProductId, |
|||
set: { |
|||
name: product.name, |
|||
description: product.description, |
|||
price: product.price, |
|||
category: product.category, |
|||
active: product.active, |
|||
stockQuantity: product.stockQuantity, |
|||
updatedAt: new Date(), |
|||
}, |
|||
}) |
|||
.returning() |
|||
|
|||
console.log(` ✓ Product ID: ${insertedProduct.id}`) |
|||
|
|||
// Assign role visibility based on category
|
|||
const roleMapping: Record<string, string[]> = { |
|||
'makerspace-annual-pass': ['private', 'educator'], |
|||
'annual-pass': ['private'], |
|||
'educator-annual-pass': ['educator'], |
|||
} |
|||
|
|||
const roles = roleMapping[product.category] || ['private'] |
|||
|
|||
for (const roleCode of roles) { |
|||
await db |
|||
.insert(productRoleVisibility) |
|||
.values({ |
|||
productId: insertedProduct.id, |
|||
roleCode: roleCode as 'private' | 'educator' | 'company', |
|||
}) |
|||
.onConflictDoNothing() |
|||
|
|||
console.log(` ✓ Assigned role: ${roleCode}`) |
|||
} |
|||
} |
|||
|
|||
console.log('✅ Products seeded successfully!') |
|||
await client.end() |
|||
} |
|||
|
|||
seedProducts().catch((error) => { |
|||
console.error('❌ Error seeding products:', error) |
|||
process.exit(1) |
|||
}) |
|||
@ -0,0 +1,74 @@ |
|||
/** |
|||
* POST /api/checkout/validate |
|||
* |
|||
* Validates checkout data before creating an order |
|||
* |
|||
* Request Body: |
|||
* { |
|||
* salutation: 'male' | 'female' | 'other', |
|||
* firstName: string, |
|||
* lastName: string, |
|||
* dateOfBirth: string (YYYY-MM-DD), |
|||
* street: string, |
|||
* postCode: string (5 digits), |
|||
* city: string, |
|||
* countryCode: string (ISO 3166-1 alpha-2, default: 'DE'), |
|||
* saveAddress: boolean (optional) |
|||
* } |
|||
* |
|||
* Response: |
|||
* { |
|||
* success: true, |
|||
* message: string |
|||
* } |
|||
* |
|||
* Errors: |
|||
* - 401: Not authenticated |
|||
* - 400: Empty cart |
|||
* - 422: Validation errors |
|||
*/ |
|||
|
|||
import { checkoutSchema } from '../../utils/schemas/checkout' |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
// 1. Require authentication
|
|||
const { user } = await requireUserSession(event) |
|||
|
|||
// 2. Check if cart has items
|
|||
const cart = await getOrCreateCart(event) |
|||
const cartSummary = await getCartWithItems(cart.id) |
|||
|
|||
if (cartSummary.itemCount === 0) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'Cart is empty', |
|||
message: 'Dein Warenkorb ist leer. Füge Produkte hinzu, um fortzufahren.', |
|||
}) |
|||
} |
|||
|
|||
// 3. Validate checkout data
|
|||
const body = await readBody(event) |
|||
|
|||
try { |
|||
const validatedData = await checkoutSchema.parseAsync(body) |
|||
|
|||
return { |
|||
success: true, |
|||
message: 'Checkout-Daten sind gültig', |
|||
data: validatedData, |
|||
} |
|||
} catch (error: any) { |
|||
// Zod validation errors
|
|||
if (error.errors) { |
|||
throw createError({ |
|||
statusCode: 422, |
|||
statusMessage: 'Validation error', |
|||
message: 'Bitte überprüfe deine Eingaben', |
|||
data: error.errors, |
|||
}) |
|||
} |
|||
|
|||
// Unknown error
|
|||
throw error |
|||
} |
|||
}) |
|||
@ -0,0 +1,89 @@ |
|||
/** |
|||
* GET /api/orders/[id] |
|||
* |
|||
* Fetch order details by ID |
|||
* |
|||
* Security: |
|||
* - Requires authentication |
|||
* - Users can only access their own orders |
|||
* |
|||
* Response: |
|||
* { |
|||
* id: string |
|||
* orderNumber: string |
|||
* totalAmount: string |
|||
* status: string |
|||
* billingAddress: BillingAddress |
|||
* items: OrderItem[] |
|||
* createdAt: Date |
|||
* } |
|||
*/ |
|||
|
|||
import { eq, and } from 'drizzle-orm' |
|||
import { orders, orderItems } from '../../database/schema' |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
// Require authentication
|
|||
const { user } = await requireUserSession(event) |
|||
|
|||
// Get order ID from URL parameter
|
|||
const orderId = getRouterParam(event, 'id') |
|||
|
|||
if (!orderId) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'Order ID is required', |
|||
}) |
|||
} |
|||
|
|||
const db = useDatabase() |
|||
|
|||
// Fetch order with items
|
|||
const order = await db.query.orders.findFirst({ |
|||
where: and(eq(orders.id, orderId), eq(orders.userId, user.id)), |
|||
with: { |
|||
items: { |
|||
with: { |
|||
product: true, |
|||
}, |
|||
}, |
|||
}, |
|||
}) |
|||
|
|||
if (!order) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Order not found', |
|||
}) |
|||
} |
|||
|
|||
// Transform items to include price and product snapshot data
|
|||
const transformedItems = order.items.map((item: any) => ({ |
|||
id: item.id, |
|||
orderId: item.orderId, |
|||
productId: item.productId, |
|||
quantity: item.quantity, |
|||
priceSnapshot: item.priceSnapshot, |
|||
productSnapshot: item.productSnapshot, |
|||
product: { |
|||
id: item.product.id, |
|||
name: item.product.name, |
|||
description: item.product.description, |
|||
imageUrl: item.product.imageUrl, |
|||
}, |
|||
subtotal: Number.parseFloat(item.priceSnapshot) * item.quantity, |
|||
})) |
|||
|
|||
return { |
|||
id: order.id, |
|||
orderNumber: order.orderNumber, |
|||
totalAmount: order.totalAmount, |
|||
status: order.status, |
|||
billingAddress: order.billingAddress, |
|||
items: transformedItems, |
|||
paymentId: order.paymentId, |
|||
paymentCompletedAt: order.paymentCompletedAt, |
|||
createdAt: order.createdAt, |
|||
updatedAt: order.updatedAt, |
|||
} |
|||
}) |
|||
@ -0,0 +1,85 @@ |
|||
/** |
|||
* POST /api/orders/confirm/[id] |
|||
* |
|||
* Confirm an order after mock payment |
|||
* |
|||
* Security: |
|||
* - Requires authentication |
|||
* - Users can only confirm their own orders |
|||
* - Order must be in 'pending' status |
|||
* |
|||
* Behavior: |
|||
* - Updates order status: 'pending' → 'completed' |
|||
* - Stores completion timestamp |
|||
* - Clears user's cart |
|||
* - Returns order details |
|||
* |
|||
* Response: |
|||
* { |
|||
* success: true |
|||
* order: Order |
|||
* message: string |
|||
* } |
|||
*/ |
|||
|
|||
import { eq, and } from 'drizzle-orm' |
|||
import { orders, cartItems } from '../../../database/schema' |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
// Require authentication
|
|||
const { user } = await requireUserSession(event) |
|||
|
|||
// Get order ID from URL parameter
|
|||
const orderId = getRouterParam(event, 'id') |
|||
|
|||
if (!orderId) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'Order ID is required', |
|||
}) |
|||
} |
|||
|
|||
const db = useDatabase() |
|||
|
|||
// Fetch order
|
|||
const order = await db.query.orders.findFirst({ |
|||
where: and(eq(orders.id, orderId), eq(orders.userId, user.id)), |
|||
}) |
|||
|
|||
if (!order) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Order not found', |
|||
}) |
|||
} |
|||
|
|||
// Validate order status
|
|||
if (order.status !== 'pending') { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: `Order cannot be confirmed. Current status: ${order.status}`, |
|||
}) |
|||
} |
|||
|
|||
// Update order status to completed
|
|||
const [updatedOrder] = await db |
|||
.update(orders) |
|||
.set({ |
|||
status: 'completed', |
|||
paymentCompletedAt: new Date(), |
|||
paymentId: `MOCK-${Date.now()}`, // Mock payment ID
|
|||
updatedAt: new Date(), |
|||
}) |
|||
.where(eq(orders.id, orderId)) |
|||
.returning() |
|||
|
|||
// Clear user's cart
|
|||
const cart = await getOrCreateCart(event) |
|||
await db.delete(cartItems).where(eq(cartItems.cartId, cart.id)) |
|||
|
|||
return { |
|||
success: true, |
|||
order: updatedOrder, |
|||
message: 'Bestellung erfolgreich bestätigt', |
|||
} |
|||
}) |
|||
@ -0,0 +1,145 @@ |
|||
/** |
|||
* POST /api/orders/create |
|||
* |
|||
* Create a new order from the user's cart |
|||
* |
|||
* Request Body: |
|||
* { |
|||
* salutation: 'male' | 'female' | 'other' |
|||
* firstName: string |
|||
* lastName: string |
|||
* dateOfBirth: string (YYYY-MM-DD) |
|||
* street: string |
|||
* postCode: string |
|||
* city: string |
|||
* countryCode: string |
|||
* saveAddress: boolean (optional, default: false) |
|||
* } |
|||
* |
|||
* Behavior: |
|||
* - Creates order with status 'pending' |
|||
* - Copies cart items to order_items with price snapshot |
|||
* - Stores billing address snapshot in order |
|||
* - Generates unique order number (format: EXP-2025-00001) |
|||
* - Optionally saves address to user profile |
|||
* - Does NOT clear cart (cart is cleared after order confirmation) |
|||
* |
|||
* Response: |
|||
* { |
|||
* success: true |
|||
* orderId: string |
|||
* orderNumber: string |
|||
* message: string |
|||
* } |
|||
*/ |
|||
|
|||
import { checkoutSchema } from '../../utils/schemas/checkout' |
|||
import { orders, orderItems, users } from '../../database/schema' |
|||
import { eq, desc, sql } from 'drizzle-orm' |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
// Require authentication
|
|||
const { user } = await requireUserSession(event) |
|||
|
|||
// Validate request body
|
|||
const body = await readBody(event) |
|||
const checkoutData = await checkoutSchema.parseAsync(body) |
|||
|
|||
const db = useDatabase() |
|||
|
|||
// Get user's cart
|
|||
const cart = await getOrCreateCart(event) |
|||
const cartSummary = await getCartWithItems(cart.id) |
|||
|
|||
// Validate cart has items
|
|||
if (cartSummary.items.length === 0) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'Warenkorb ist leer', |
|||
}) |
|||
} |
|||
|
|||
// Generate unique order number
|
|||
// Format: EXP-YYYY-NNNNN (e.g., EXP-2025-00001)
|
|||
const year = new Date().getFullYear() |
|||
|
|||
// Get the highest order number for this year
|
|||
const lastOrder = await db.query.orders.findFirst({ |
|||
where: sql`${orders.orderNumber} LIKE ${`EXP-${year}-%`}`, |
|||
orderBy: desc(orders.createdAt), |
|||
}) |
|||
|
|||
let sequenceNumber = 1 |
|||
if (lastOrder) { |
|||
// Extract sequence number from last order number (EXP-2025-00123 -> 123)
|
|||
const match = lastOrder.orderNumber.match(/EXP-\d{4}-(\d{5})/) |
|||
if (match) { |
|||
sequenceNumber = Number.parseInt(match[1], 10) + 1 |
|||
} |
|||
} |
|||
|
|||
const orderNumber = `EXP-${year}-${String(sequenceNumber).padStart(5, '0')}` |
|||
|
|||
// Prepare billing address (exclude saveAddress flag)
|
|||
const billingAddress = { |
|||
salutation: checkoutData.salutation, |
|||
firstName: checkoutData.firstName, |
|||
lastName: checkoutData.lastName, |
|||
dateOfBirth: checkoutData.dateOfBirth, |
|||
street: checkoutData.street, |
|||
postCode: checkoutData.postCode, |
|||
city: checkoutData.city, |
|||
countryCode: checkoutData.countryCode, |
|||
} |
|||
|
|||
// Create order
|
|||
const [order] = await db |
|||
.insert(orders) |
|||
.values({ |
|||
orderNumber, |
|||
userId: user.id, |
|||
totalAmount: cartSummary.total.toFixed(2), |
|||
status: 'pending', // Order starts as pending (awaiting mock payment)
|
|||
billingAddress, |
|||
}) |
|||
.returning() |
|||
|
|||
// Create order items with price snapshots
|
|||
const orderItemsData = cartSummary.items.map((item) => ({ |
|||
orderId: order.id, |
|||
productId: item.productId, |
|||
quantity: item.quantity, |
|||
priceSnapshot: item.product.price, // Snapshot price at time of order
|
|||
productSnapshot: { |
|||
name: item.product.name, |
|||
description: item.product.description, |
|||
navProductId: item.product.navProductId, |
|||
category: item.product.category, |
|||
}, |
|||
})) |
|||
|
|||
await db.insert(orderItems).values(orderItemsData) |
|||
|
|||
// Optionally save address to user profile
|
|||
if (checkoutData.saveAddress) { |
|||
await db |
|||
.update(users) |
|||
.set({ |
|||
salutation: checkoutData.salutation, |
|||
dateOfBirth: new Date(checkoutData.dateOfBirth), |
|||
street: checkoutData.street, |
|||
postCode: checkoutData.postCode, |
|||
city: checkoutData.city, |
|||
countryCode: checkoutData.countryCode, |
|||
updatedAt: new Date(), |
|||
}) |
|||
.where(eq(users.id, user.id)) |
|||
} |
|||
|
|||
return { |
|||
success: true, |
|||
orderId: order.id, |
|||
orderNumber: order.orderNumber, |
|||
message: 'Bestellung erfolgreich erstellt', |
|||
} |
|||
}) |
|||
@ -0,0 +1,87 @@ |
|||
/** |
|||
* POST /api/payment/mock-paypal |
|||
* |
|||
* Mock PayPal payment endpoint for MVP development |
|||
* |
|||
* This endpoint simulates a PayPal payment without making actual API calls. |
|||
* It's used for testing the checkout flow end-to-end before real PayPal integration. |
|||
* |
|||
* Request Body: |
|||
* { |
|||
* orderId: string (UUID) |
|||
* } |
|||
* |
|||
* Behavior: |
|||
* - Validates order exists and belongs to logged-in user |
|||
* - Validates order status is 'pending' |
|||
* - Returns immediate "success" response with mock payment ID |
|||
* - Does NOT update order status (that happens in /api/orders/confirm/[id]) |
|||
* |
|||
* Response: |
|||
* { |
|||
* success: true, |
|||
* paymentId: string (mock ID), |
|||
* message: string |
|||
* } |
|||
* |
|||
* Errors: |
|||
* - 401: Not authenticated |
|||
* - 400: Invalid request |
|||
* - 404: Order not found |
|||
*/ |
|||
|
|||
import { z } from 'zod' |
|||
import { eq, and } from 'drizzle-orm' |
|||
import { orders } from '../../database/schema' |
|||
|
|||
const mockPaymentSchema = z.object({ |
|||
orderId: z.string().uuid('Invalid order ID'), |
|||
}) |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
// 1. Require authentication
|
|||
const { user } = await requireUserSession(event) |
|||
|
|||
// 2. Validate request body
|
|||
const body = await readBody(event) |
|||
const { orderId } = await mockPaymentSchema.parseAsync(body) |
|||
|
|||
const db = useDatabase() |
|||
|
|||
// 3. Fetch order
|
|||
const order = await db.query.orders.findFirst({ |
|||
where: and(eq(orders.id, orderId), eq(orders.userId, user.id)), |
|||
}) |
|||
|
|||
if (!order) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: 'Order not found', |
|||
message: 'Bestellung wurde nicht gefunden', |
|||
}) |
|||
} |
|||
|
|||
// 4. Validate order status
|
|||
if (order.status !== 'pending') { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'Invalid order status', |
|||
message: `Bestellung kann nicht bezahlt werden. Status: ${order.status}`, |
|||
}) |
|||
} |
|||
|
|||
// 5. Simulate PayPal processing delay (optional)
|
|||
// In real implementation, this would be replaced with actual PayPal API call
|
|||
await new Promise((resolve) => setTimeout(resolve, 500)) // 500ms delay
|
|||
|
|||
// 6. Generate mock payment ID
|
|||
const mockPaymentId = `MOCK-PAYPAL-${Date.now()}-${orderId.slice(0, 8)}` |
|||
|
|||
// 7. Return success response
|
|||
// Note: Order status is NOT updated here. That happens in /api/orders/confirm/[id]
|
|||
return { |
|||
success: true, |
|||
paymentId: mockPaymentId, |
|||
message: 'Mock-Zahlung erfolgreich', |
|||
} |
|||
}) |
|||
@ -0,0 +1 @@ |
|||
ALTER TABLE "products" ADD COLUMN "image_url" text; |
|||
@ -0,0 +1 @@ |
|||
ALTER TABLE "products" ADD COLUMN "image_url" text; |
|||
File diff suppressed because it is too large
@ -0,0 +1,97 @@ |
|||
/** |
|||
* Checkout Schema |
|||
* |
|||
* Validation schema for billing address data during checkout. |
|||
* All fields are required at checkout (even though optional in users table). |
|||
*/ |
|||
|
|||
import { z } from 'zod' |
|||
|
|||
/** |
|||
* Salutation validation |
|||
* Maps to: male → HERR, female → FRAU, other → K_ANGABE (X-API format) |
|||
*/ |
|||
export const salutationEnum = z.enum(['male', 'female', 'other'], { |
|||
errorMap: () => ({ message: 'Bitte wähle eine Anrede' }), |
|||
}) |
|||
|
|||
/** |
|||
* German postal code validation (5 digits) |
|||
* Format: XXXXX (e.g., 74072, 10115) |
|||
*/ |
|||
const germanPostCodeRegex = /^\d{5}$/ |
|||
|
|||
/** |
|||
* Date validation in YYYY-MM-DD format |
|||
* Must be a valid date in the past (birth date) |
|||
*/ |
|||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/ |
|||
|
|||
/** |
|||
* Complete checkout schema |
|||
* |
|||
* Validates all billing address fields required for annual pass orders. |
|||
* These fields are also used for saving to user profile if "save address" checkbox is checked. |
|||
*/ |
|||
export const checkoutSchema = z.object({ |
|||
// Personal information
|
|||
salutation: salutationEnum, |
|||
firstName: z |
|||
.string() |
|||
.min(2, 'Vorname muss mindestens 2 Zeichen lang sein') |
|||
.max(100, 'Vorname darf maximal 100 Zeichen lang sein') |
|||
.trim(), |
|||
lastName: z |
|||
.string() |
|||
.min(2, 'Nachname muss mindestens 2 Zeichen lang sein') |
|||
.max(100, 'Nachname darf maximal 100 Zeichen lang sein') |
|||
.trim(), |
|||
dateOfBirth: z |
|||
.string() |
|||
.regex(dateRegex, 'Geburtsdatum muss im Format YYYY-MM-DD sein') |
|||
.refine( |
|||
(date) => { |
|||
const parsed = new Date(date) |
|||
// Check if date is valid and in the past
|
|||
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' } |
|||
), |
|||
|
|||
// Address information
|
|||
street: z |
|||
.string() |
|||
.min(3, 'Straße und Hausnummer müssen mindestens 3 Zeichen lang sein') |
|||
.max(200, 'Straße darf maximal 200 Zeichen lang sein') |
|||
.trim(), |
|||
postCode: z |
|||
.string() |
|||
.regex(germanPostCodeRegex, 'Postleitzahl muss aus 5 Ziffern bestehen (z.B. 74072)') |
|||
.trim(), |
|||
city: z |
|||
.string() |
|||
.min(2, 'Stadt muss mindestens 2 Zeichen lang sein') |
|||
.max(100, 'Stadt darf maximal 100 Zeichen lang sein') |
|||
.trim(), |
|||
countryCode: z |
|||
.string() |
|||
.length(2, 'Ländercode muss aus 2 Zeichen bestehen (ISO 3166-1 alpha-2)') |
|||
.toUpperCase() |
|||
.default('DE'), // Default to Germany
|
|||
|
|||
// Optional: flag to save address to user profile
|
|||
saveAddress: z.boolean().optional().default(false), |
|||
}) |
|||
|
|||
/** |
|||
* TypeScript type inferred from Zod schema |
|||
*/ |
|||
export type CheckoutData = z.infer<typeof checkoutSchema> |
|||
|
|||
/** |
|||
* Billing address type (subset of CheckoutData, without saveAddress flag) |
|||
* Used for storing in order.billingAddress JSONB field |
|||
*/ |
|||
export type BillingAddress = Omit<CheckoutData, 'saveAddress'> |
|||
Loading…
Reference in new issue