Enhance checkout flow with new components and validation

- Added AddressForm and CheckoutForm components for user input during checkout.
- Implemented validation using Zod and VeeValidate for billing address fields.
- Created OrderSummary and MockPayPalButton components for order confirmation and payment simulation.
- Updated CartSheet and CartSidebar to navigate to the new checkout page at '/kasse'.
- Introduced new API endpoints for validating checkout data and creating orders.
- Enhanced user experience with responsive design and error handling.

These changes complete the checkout functionality, allowing users to enter billing information, simulate payment, and confirm orders.
This commit is contained in:
Bastian Masanek
2025-11-03 15:38:16 +01:00
parent 47fe14c6cc
commit 527379a2cd
44 changed files with 4957 additions and 142 deletions

View File

@@ -27,7 +27,7 @@ async function handleRemoveItem(itemId: string) {
// Navigate to checkout
function handleCheckout() {
close()
navigateTo('/checkout')
navigateTo('/kasse')
}
</script>
@@ -51,25 +51,14 @@ function handleCheckout() {
<!-- Scrollable Items List -->
<ScrollArea class="flex-1 px-6">
<div class="space-y-4 py-4">
<CartItem
v-for="item in items"
:key="item.id"
:item="item"
:loading="loading"
@update:quantity="(qty) => handleUpdateQuantity(item.id, qty)"
@remove="handleRemoveItem(item.id)"
/>
<CartItem v-for="item in items" :key="item.id" :item="item" :loading="loading"
@update:quantity="(qty) => handleUpdateQuantity(item.id, qty)" @remove="handleRemoveItem(item.id)" />
</div>
</ScrollArea>
<!-- Sticky Footer with Summary -->
<div class="border-t px-6 py-4 bg-background">
<CartSummary
:items="items"
:total="total"
:loading="loading"
@checkout="handleCheckout"
/>
<CartSummary :items="items" :total="total" :loading="loading" @checkout="handleCheckout" />
</div>
</template>
</SheetContent>

View File

@@ -27,7 +27,7 @@ async function handleRemoveItem(itemId: string) {
// Navigate to checkout
function handleCheckout() {
close()
navigateTo('/checkout')
navigateTo('/kasse')
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { default as Checkbox } from './Checkbox.vue'

View File

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

View File

@@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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