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:
@@ -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>
|
||||
|
||||
@@ -27,7 +27,7 @@ async function handleRemoveItem(itemId: string) {
|
||||
// Navigate to checkout
|
||||
function handleCheckout() {
|
||||
close()
|
||||
navigateTo('/checkout')
|
||||
navigateTo('/kasse')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
309
app/components/Checkout/AddressForm.vue
Normal file
309
app/components/Checkout/AddressForm.vue
Normal 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>
|
||||
299
app/components/Checkout/CheckoutForm.vue
Normal file
299
app/components/Checkout/CheckoutForm.vue
Normal 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>
|
||||
187
app/components/Order/OrderSummary.vue
Normal file
187
app/components/Order/OrderSummary.vue
Normal 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>
|
||||
149
app/components/Payment/MockPayPalButton.vue
Normal file
149
app/components/Payment/MockPayPalButton.vue
Normal 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>
|
||||
41
app/components/ui/checkbox/Checkbox.vue
Normal file
41
app/components/ui/checkbox/Checkbox.vue
Normal 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>
|
||||
1
app/components/ui/checkbox/index.ts
Normal file
1
app/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from './Checkbox.vue'
|
||||
31
app/components/ui/label/Label.vue
Normal file
31
app/components/ui/label/Label.vue
Normal 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>
|
||||
1
app/components/ui/label/index.ts
Normal file
1
app/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from './Label.vue'
|
||||
22
app/components/ui/select/Select.vue
Normal file
22
app/components/ui/select/Select.vue
Normal 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>
|
||||
43
app/components/ui/select/SelectContent.vue
Normal file
43
app/components/ui/select/SelectContent.vue
Normal 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>
|
||||
41
app/components/ui/select/SelectItem.vue
Normal file
41
app/components/ui/select/SelectItem.vue
Normal 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>
|
||||
33
app/components/ui/select/SelectTrigger.vue
Normal file
33
app/components/ui/select/SelectTrigger.vue
Normal 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>
|
||||
15
app/components/ui/select/SelectValue.vue
Normal file
15
app/components/ui/select/SelectValue.vue
Normal 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>
|
||||
5
app/components/ui/select/index.ts
Normal file
5
app/components/ui/select/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user