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:
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>
|
||||
Reference in New Issue
Block a user