feat(portal): registration API fields, field_values, conditional steps

Wire registration_fields and event toggles into the existing wizard;
replace hardcoded step content with dynamic controls; submit
field_values and festival_section_id section_preferences; map 422
field_values.* to inline errors.

Made-with: Cursor
This commit is contained in:
2026-04-13 00:17:58 +02:00
parent 63e2e5bed7
commit 4df82d8358
3 changed files with 485 additions and 248 deletions

View File

@@ -6,6 +6,8 @@ import { useAuthStore } from '@/stores/useAuthStore'
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration' import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
import { fullRegistrationSchema } from '@/schemas/registrationSchema' import { fullRegistrationSchema } from '@/schemas/registrationSchema'
import type { import type {
RegistrationField,
RegistrationFieldType,
SectionOption, SectionOption,
SectionPreference, SectionPreference,
TimeSlotOption, TimeSlotOption,
@@ -32,9 +34,10 @@ const { mutateAsync: submitRegistration, isPending: isSubmitting } = useSubmitRe
const currentStep = ref(0) const currentStep = ref(0)
const submitError = ref<string | null>(null) const submitError = ref<string | null>(null)
const fieldFormData = ref<Record<string, unknown>>({})
const fieldErrors = ref<Record<string, string>>({})
// VeeValidate form const { errors, defineField, validateField, setFieldValue, setFieldError } = useForm({
const { errors, defineField, validateField, setFieldValue } = useForm({
validationSchema: toTypedSchema(fullRegistrationSchema), validationSchema: toTypedSchema(fullRegistrationSchema),
initialValues: { initialValues: {
first_name: '', first_name: '',
@@ -42,12 +45,6 @@ const { errors, defineField, validateField, setFieldValue } = useForm({
email: '', email: '',
date_of_birth: '', date_of_birth: '',
phone: '', phone: '',
tshirt_size: '',
first_aid: false,
allergies: '',
driving_licence: false,
motivation: '',
motivation_other: '',
}, },
}) })
@@ -56,12 +53,6 @@ const [lastName] = defineField('last_name')
const [dateOfBirth] = defineField('date_of_birth') const [dateOfBirth] = defineField('date_of_birth')
const [email] = defineField('email') const [email] = defineField('email')
const [phone] = defineField('phone') const [phone] = defineField('phone')
const [tshirtSize] = defineField('tshirt_size')
const [firstAid] = defineField('first_aid')
const [allergies] = defineField('allergies')
const [drivingLicence] = defineField('driving_licence')
const [motivation] = defineField('motivation')
const [motivationOther] = defineField('motivation_other')
// Pre-fill authenticated user data // Pre-fill authenticated user data
watch(() => authStore.user, user => { watch(() => authStore.user, user => {
@@ -72,36 +63,84 @@ watch(() => authStore.user, user => {
} }
}, { immediate: true }) }, { immediate: true })
// Step 3: Section preferences (by name, not ID) const selectedSectionIds = ref<string[]>([])
const selectedSections = ref<string[]>([])
// Step 4: Availability
const selectedTimeSlotIds = ref<string[]>([]) const selectedTimeSlotIds = ref<string[]>([])
const timeSlotPreferences = ref<Record<string, number>>({}) const timeSlotPreferences = ref<Record<string, number>>({})
// Steps definition const showSections = computed(() => Boolean(registrationData.value?.event.registration_show_section_preferences))
const steps = [ const showAvailability = computed(() => Boolean(registrationData.value?.event.registration_show_availability))
{ title: 'Over jou', subtitle: 'Vul je persoonlijke gegevens in' },
{ title: 'Meer over jou', subtitle: 'Praktische informatie' },
{ title: 'Motivatie', subtitle: 'Waarom wil je meehelpen?' },
{ title: 'Secties', subtitle: 'Waar wil je het liefst werken?' },
{ title: 'Beschikbaarheid', subtitle: 'Wanneer kun je helpen?' },
]
// Constants const steps = computed(() => {
const tshirtSizeItems = [ const list = [
{ title: 'Geen voorkeur', value: '' }, { title: 'Over jou', subtitle: 'Vul je persoonlijke gegevens in' },
...['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'].map(s => ({ title: s, value: s })), { title: 'Extra informatie', subtitle: 'Antwoord op de vragen van de organisatie' },
] ]
if (showSections.value)
list.push({ title: 'Secties', subtitle: 'Waar wil je het liefst werken?' })
if (showAvailability.value)
list.push({ title: 'Beschikbaarheid', subtitle: 'Wanneer kun je helpen?' })
const motivationItems = [ return list
{ title: 'Gratis festivalpas', value: 'Gratis festivalpas' }, })
{ title: 'Ervaring opdoen', value: 'Ervaring opdoen' },
{ title: 'Vrienden helpen', value: 'Vrienden helpen' }, const sectionsStepIndex = computed(() => (showSections.value ? 2 : -1))
{ title: 'CV opbouwen', value: 'CV opbouwen' }, const availabilityStepIndex = computed(() => {
{ title: 'Ik ben gevraagd', value: 'Ik ben gevraagd' }, if (!showAvailability.value) return -1
{ title: 'Anders', value: 'Anders' },
] return showSections.value ? 3 : 2
})
const currentStepKind = computed(() => {
if (currentStep.value === 0) return 'personal'
if (currentStep.value === 1) return 'dynamic'
if (sectionsStepIndex.value !== -1 && currentStep.value === sectionsStepIndex.value) return 'sections'
if (availabilityStepIndex.value !== -1 && currentStep.value === availabilityStepIndex.value) return 'availability'
return 'personal'
})
const registrationFieldsList = computed(() => registrationData.value?.registration_fields ?? [])
const groupedRegistrationFields = computed(() => {
const fields = registrationFieldsList.value
const groups: { section: string | null; fields: RegistrationField[] }[] = []
for (const f of fields) {
const last = groups[groups.length - 1]
if (last && last.section === f.section)
last.fields.push(f)
else
groups.push({ section: f.section, fields: [f] })
}
return groups
})
function defaultFieldValue(type: RegistrationFieldType): unknown {
switch (type) {
case 'multiselect':
case 'checkbox':
case 'tag_picker':
return []
case 'boolean':
return false
case 'number':
return null
default:
return ''
}
}
watch(registrationFieldsList, fields => {
for (const f of fields) {
if (!(f.slug in fieldFormData.value))
fieldFormData.value[f.slug] = defaultFieldValue(f.field_type)
}
}, { immediate: true })
watch(steps, s => {
if (currentStep.value >= s.length)
currentStep.value = Math.max(0, s.length - 1)
})
// Section helpers // Section helpers
const sectionsByCategory = computed(() => { const sectionsByCategory = computed(() => {
@@ -116,15 +155,15 @@ const sectionsByCategory = computed(() => {
}, {} as Record<string, SectionOption[]>) }, {} as Record<string, SectionOption[]>)
}) })
function isSelected(sectionName: string) { function isSelected(sectionId: string) {
return selectedSections.value.includes(sectionName) return selectedSectionIds.value.includes(sectionId)
} }
function getSelectionPriority(sectionName: string) { function getSelectionPriority(sectionId: string) {
return selectedSections.value.indexOf(sectionName) + 1 return selectedSectionIds.value.indexOf(sectionId) + 1
} }
const selectedCount = computed(() => selectedSections.value.length) const selectedCount = computed(() => selectedSectionIds.value.length)
// Computed // Computed
const timeSlotsByDate = computed(() => { const timeSlotsByDate = computed(() => {
@@ -158,27 +197,108 @@ const formattedDates = computed(() => {
) )
}) })
// Step field mapping for validation (0-based) const personalFieldKeys = ['first_name', 'last_name', 'date_of_birth', 'email', 'phone'] as const
type FormField = 'first_name' | 'last_name' | 'date_of_birth' | 'email' | 'phone' | 'tshirt_size' | 'first_aid' | 'allergies' | 'driving_licence' | 'motivation' | 'motivation_other'
const stepFields: Record<number, FormField[]> = { function isMultiValueType(t: RegistrationFieldType): boolean {
0: ['first_name', 'last_name', 'date_of_birth', 'email', 'phone'], return t === 'multiselect' || t === 'checkbox' || t === 'tag_picker'
1: ['tshirt_size', 'first_aid', 'allergies', 'driving_licence'],
2: ['motivation', 'motivation_other'],
} }
// Navigation function isEmptyFieldValue(value: unknown, type: RegistrationFieldType): boolean {
async function validateCurrentStep(): Promise<boolean> { if (value === undefined || value === null) return true
const fields = stepFields[currentStep.value] if (value === '') return true
if (!fields) return true if (type === 'number' && (typeof value !== 'number' || Number.isNaN(value))) return true
const results = await Promise.all(fields.map(f => validateField(f))) if (isMultiValueType(type)) {
if (!Array.isArray(value)) return true
return results.every(r => r.valid) return value.length === 0
}
return false
}
function ensureCheckboxArray(slug: string): string[] {
const v = fieldFormData.value[slug]
if (Array.isArray(v)) return v.map(String)
fieldFormData.value[slug] = []
return fieldFormData.value[slug] as string[]
}
function toggleCheckboxOption(slug: string, option: string, checked: boolean | null) {
const arr = ensureCheckboxArray(slug)
const on = Boolean(checked)
if (on) {
if (!arr.includes(option)) arr.push(option)
}
else {
const i = arr.indexOf(option)
if (i !== -1) arr.splice(i, 1)
}
fieldFormData.value[slug] = [...arr]
}
function isCheckboxChecked(slug: string, option: string): boolean {
const v = fieldFormData.value[slug]
return Array.isArray(v) && v.includes(option)
}
function numberFieldModel(slug: string): string {
const v = fieldFormData.value[slug]
if (v === null || v === undefined) return ''
if (typeof v === 'number' && Number.isNaN(v)) return ''
return String(v)
}
function onNumberFieldInput(slug: string, raw: string) {
if (raw === '' || raw === '-') {
fieldFormData.value[slug] = null
return
}
const n = Number(raw)
fieldFormData.value[slug] = Number.isFinite(n) ? n : null
}
function validateDynamicFields(): boolean {
fieldErrors.value = {}
let firstErrorSlug: string | null = null
for (const field of registrationFieldsList.value) {
if (!field.is_required) continue
const val = fieldFormData.value[field.slug]
if (isEmptyFieldValue(val, field.field_type)) {
fieldErrors.value[field.slug] = 'Dit veld is verplicht.'
firstErrorSlug ??= field.slug
}
}
if (firstErrorSlug) {
nextTick(() => {
document.getElementById(`reg-field-${firstErrorSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
return false
}
return true
}
async function validateCurrentStep(): Promise<boolean> {
const k = currentStepKind.value
if (k === 'personal') {
const results = await Promise.all(personalFieldKeys.map(f => validateField(f)))
return results.every(r => r.valid)
}
if (k === 'dynamic')
return validateDynamicFields()
return true
} }
async function nextStep() { async function nextStep() {
if (await validateCurrentStep()) { if (await validateCurrentStep()) {
if (currentStep.value < 4) currentStep.value++ if (currentStep.value < steps.value.length - 1) currentStep.value++
} }
} }
@@ -192,14 +312,13 @@ function goToStep(index: number) {
} }
} }
// Section toggle function toggleSection(sectionId: string) {
function toggleSection(sectionName: string) { const idx = selectedSectionIds.value.indexOf(sectionId)
const idx = selectedSections.value.indexOf(sectionName)
if (idx !== -1) { if (idx !== -1) {
selectedSections.value.splice(idx, 1) selectedSectionIds.value.splice(idx, 1)
} }
else if (selectedSections.value.length < 5) { else if (selectedSectionIds.value.length < 5) {
selectedSections.value.push(sectionName) selectedSectionIds.value.push(sectionId)
} }
} }
@@ -240,29 +359,69 @@ function formatTimeRange(start: string, end: string): string {
return `${start.slice(0, 5)} ${end.slice(0, 5)}` return `${start.slice(0, 5)} ${end.slice(0, 5)}`
} }
// Submit function buildFieldValuesPayload(): Record<string, unknown> | undefined {
const out: Record<string, unknown> = {}
for (const field of registrationFieldsList.value) {
const val = fieldFormData.value[field.slug]
if (field.field_type === 'boolean') {
if (typeof val === 'boolean')
out[field.slug] = val
continue
}
if (val !== undefined && val !== null && val !== '') {
if (Array.isArray(val) && val.length === 0) continue
out[field.slug] = val
}
}
return Object.keys(out).length ? out : undefined
}
function isPersonalFieldKey(key: string): key is typeof personalFieldKeys[number] {
return (personalFieldKeys as readonly string[]).includes(key)
}
function applyServerValidationErrors(serverErrors: Record<string, string[]>) {
fieldErrors.value = {}
for (const [key, msgs] of Object.entries(serverErrors)) {
const m = /^field_values\.(.+)$/.exec(key)
if (m?.[1]) {
fieldErrors.value[m[1]] = msgs[0] ?? 'Ongeldige waarde.'
continue
}
if (isPersonalFieldKey(key))
setFieldError(key, msgs[0] ?? 'Ongeldige waarde.')
}
}
async function onSubmit() { async function onSubmit() {
submitError.value = null submitError.value = null
fieldErrors.value = {}
// Validate steps 0-2 const rPersonal = await Promise.all(personalFieldKeys.map(f => validateField(f)))
for (let step = 0; step <= 2; step++) { if (!rPersonal.every(x => x.valid)) {
const fields = stepFields[step] currentStep.value = 0
if (!fields) continue
const results = await Promise.all(fields.map(f => validateField(f)))
if (!results.every(r => r.valid)) { return
currentStep.value = step }
if (!validateDynamicFields()) {
currentStep.value = 1
return return
}
} }
if (!registrationData.value) return if (!registrationData.value) return
const sectionPreferences: SectionPreference[] = selectedSections.value.map((sectionName, index) => ({ const field_values = buildFieldValuesPayload()
section_name: sectionName,
priority: index + 1, const section_preferences: SectionPreference[] | undefined
})) = showSections.value && selectedSectionIds.value.length > 0
? selectedSectionIds.value.map((sectionId, index) => ({
festival_section_id: sectionId,
priority: index + 1,
}))
: undefined
const availabilities: VolunteerAvailability[] = selectedTimeSlotIds.value.map(id => ({ const availabilities: VolunteerAvailability[] = selectedTimeSlotIds.value.map(id => ({
time_slot_id: id, time_slot_id: id,
@@ -275,16 +434,12 @@ async function onSubmit() {
date_of_birth: dateOfBirth.value ?? '', date_of_birth: dateOfBirth.value ?? '',
email: email.value ?? '', email: email.value ?? '',
phone: phone.value ?? '', phone: phone.value ?? '',
tshirt_size: tshirtSize.value ?? '',
first_aid: firstAid.value ?? false,
allergies: allergies.value ?? '',
driving_licence: drivingLicence.value ?? false,
motivation: motivation.value ?? '',
motivation_other: motivationOther.value ?? '',
section_preferences: sectionPreferences,
availabilities, availabilities,
} }
if (field_values) payload.field_values = field_values
if (section_preferences?.length) payload.section_preferences = section_preferences
try { try {
await submitRegistration({ await submitRegistration({
eventId: registrationData.value.event.id, eventId: registrationData.value.event.id,
@@ -307,14 +462,22 @@ async function onSubmit() {
const serverErrors = axiosError.response.data?.errors const serverErrors = axiosError.response.data?.errors
if (serverErrors) { if (serverErrors) {
for (const field of Object.keys(serverErrors)) { applyServerValidationErrors(serverErrors)
for (const [step, fields] of Object.entries(stepFields)) { const keys = Object.keys(serverErrors)
if (fields.includes(field as FormField)) { if (keys.some(k => isPersonalFieldKey(k))) {
currentStep.value = Number(step) currentStep.value = 0
return return
} }
} if (keys.some(k => k.startsWith('field_values.'))) {
currentStep.value = 1
return
}
if (keys.some(k => k.startsWith('section_preferences'))) {
if (sectionsStepIndex.value !== -1) currentStep.value = sectionsStepIndex.value
return
} }
} }
submitError.value = 'Er zijn validatiefouten gevonden. Controleer je invoer.' submitError.value = 'Er zijn validatiefouten gevonden. Controleer je invoer.'
@@ -614,6 +777,9 @@ async function onSubmit() {
<VCardText class="pa-8"> <VCardText class="pa-8">
<!-- Step header --> <!-- Step header -->
<div class="mb-6"> <div class="mb-6">
<p class="text-caption text-medium-emphasis mb-1">
Deel {{ currentStep + 1 }} van {{ steps.length }}
</p>
<h5 class="text-h5 mb-1"> <h5 class="text-h5 mb-1">
{{ steps[currentStep].title }} {{ steps[currentStep].title }}
</h5> </h5>
@@ -752,133 +918,196 @@ async function onSubmit() {
</VRow> </VRow>
</div> </div>
<!-- Step 1: Meer over jou --> <!-- Step 1: Extra informatie (dynamic registration_fields) -->
<div v-show="currentStep === 1"> <div v-show="currentStep === 1">
<VRow> <template
<VCol v-for="(group, gi) in groupedRegistrationFields"
cols="12" :key="gi"
md="6" >
<div
v-if="group.section"
class="text-subtitle-2 text-medium-emphasis mt-4 mb-2"
> >
<label {{ group.section }}
class="text-body-2 d-block mb-1 text-high-emphasis" </div>
for="volunteer-reg-tshirt"
>
Shirtmaat
</label>
<VSelect
id="volunteer-reg-tshirt"
v-model="tshirtSize"
:items="tshirtSizeItems"
variant="outlined"
placeholder="Selecteer je maat"
:error-messages="errors.tshirt_size"
density="comfortable"
hide-details="auto"
/>
</VCol>
<VCol <VRow>
cols="12"
md="6"
class="d-flex align-end"
>
<div class="d-flex ga-6 flex-wrap pb-1 w-100">
<VSwitch
v-model="firstAid"
label="EHBO-diploma"
color="primary"
hide-details
/>
<VSwitch
v-model="drivingLicence"
label="Rijbewijs B"
color="primary"
hide-details
/>
</div>
</VCol>
<VCol cols="12">
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-allergies"
>
Allergieën of dieetwensen
</label>
<VTextarea
id="volunteer-reg-allergies"
v-model="allergies"
variant="outlined"
placeholder="Laat ons weten als je allergieën of dieetwensen hebt"
:error-messages="errors.allergies"
:counter="500"
rows="2"
auto-grow
density="comfortable"
hide-details="auto"
/>
</VCol>
</VRow>
</div>
<!-- Step 2: Motivatie -->
<div v-show="currentStep === 2">
<VRow>
<VCol
cols="12"
md="6"
>
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-motivation"
>
Waarom wil je vrijwilliger zijn?
</label>
<VSelect
id="volunteer-reg-motivation"
v-model="motivation"
:items="motivationItems"
variant="outlined"
placeholder="Selecteer je motivatie"
:error-messages="errors.motivation"
clearable
density="comfortable"
hide-details="auto"
/>
</VCol>
<VExpandTransition>
<VCol <VCol
v-if="motivation" v-for="field in group.fields"
:key="field.id"
cols="12" cols="12"
:md="field.field_type === 'textarea' || field.field_type === 'checkbox' ? '12' : '6'"
> >
<label <div :id="`reg-field-${field.slug}`">
class="text-body-2 d-block mb-1 text-high-emphasis" <VTextField
for="volunteer-reg-motivation-other" v-if="field.field_type === 'text'"
> v-model="fieldFormData[field.slug] as string"
Toelichting variant="outlined"
</label> density="comfortable"
<VTextarea hide-details="auto"
id="volunteer-reg-motivation-other" :label="field.label + (field.is_required ? ' *' : '')"
v-model="motivationOther" :hint="field.help_text ?? undefined"
variant="outlined" persistent-hint
placeholder="Vertel ons meer over je motivatie..." :error-messages="fieldErrors[field.slug]"
:error-messages="errors.motivation_other" />
:counter="500"
rows="3" <VTextField
auto-grow v-else-if="field.field_type === 'number'"
density="comfortable" :model-value="numberFieldModel(field.slug)"
hide-details="auto" variant="outlined"
/> type="number"
density="comfortable"
hide-details="auto"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
@update:model-value="(v: string | number | null) => onNumberFieldInput(field.slug, String(v ?? ''))"
/>
<VTextarea
v-else-if="field.field_type === 'textarea'"
v-model="fieldFormData[field.slug] as string"
variant="outlined"
rows="3"
auto-grow
density="comfortable"
hide-details="auto"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
<VSelect
v-else-if="field.field_type === 'select'"
v-model="fieldFormData[field.slug] as string"
variant="outlined"
density="comfortable"
hide-details="auto"
:items="field.options ?? []"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
:clearable="!field.is_required"
/>
<VSelect
v-else-if="field.field_type === 'multiselect'"
v-model="fieldFormData[field.slug] as string[]"
variant="outlined"
density="comfortable"
hide-details="auto"
multiple
chips
closable-chips
:items="field.options ?? []"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
<div v-else-if="field.field_type === 'checkbox'">
<div class="text-body-2 d-block mb-1 text-high-emphasis">
{{ field.label }}<span v-if="field.is_required" class="text-error"> *</span>
</div>
<p
v-if="field.help_text"
class="text-caption text-medium-emphasis mb-2"
>
{{ field.help_text }}
</p>
<VCheckbox
v-for="opt in (field.options ?? [])"
:key="opt"
:model-value="isCheckboxChecked(field.slug, opt)"
density="comfortable"
hide-details
:label="opt"
@update:model-value="(v: boolean | null) => toggleCheckboxOption(field.slug, opt, v)"
/>
<div
v-if="fieldErrors[field.slug]"
class="text-caption text-error"
>
{{ fieldErrors[field.slug] }}
</div>
</div>
<div v-else-if="field.field_type === 'radio'">
<div class="text-body-2 d-block mb-1 text-high-emphasis">
{{ field.label }}<span v-if="field.is_required" class="text-error"> *</span>
</div>
<p
v-if="field.help_text"
class="text-caption text-medium-emphasis mb-2"
>
{{ field.help_text }}
</p>
<VRadioGroup
v-model="fieldFormData[field.slug] as string"
density="comfortable"
hide-details="auto"
:error-messages="fieldErrors[field.slug]"
>
<VRadio
v-for="opt in (field.options ?? [])"
:key="opt"
:label="opt"
:value="opt"
density="comfortable"
hide-details
/>
</VRadioGroup>
</div>
<VSwitch
v-else-if="field.field_type === 'boolean'"
v-model="fieldFormData[field.slug] as boolean"
inset
color="primary"
density="comfortable"
hide-details="auto"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
<VSelect
v-else-if="field.field_type === 'tag_picker'"
v-model="fieldFormData[field.slug] as string[]"
variant="outlined"
density="comfortable"
hide-details="auto"
multiple
chips
closable-chips
:items="field.available_tags ?? []"
item-title="name"
item-value="id"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
</div>
</VCol> </VCol>
</VExpandTransition> </VRow>
</VRow> </template>
<p
v-if="registrationFieldsList.length === 0"
class="text-body-2 text-medium-emphasis mb-0"
>
Geen extra vragen voor dit evenement.
</p>
</div> </div>
<!-- Step 3: Secties --> <!-- Secties -->
<div v-show="currentStep === 3"> <div v-show="currentStepKind === 'sections'">
<template <template
v-for="(sections, category) in sectionsByCategory" v-for="(sections, category) in sectionsByCategory"
:key="category" :key="category"
@@ -895,15 +1124,15 @@ async function onSubmit() {
sm="6" sm="6"
> >
<VCard <VCard
:variant="isSelected(section.name) ? 'flat' : 'outlined'" :variant="isSelected(section.id) ? 'flat' : 'outlined'"
:color="isSelected(section.name) ? 'primary' : undefined" :color="isSelected(section.id) ? 'primary' : undefined"
class="cursor-pointer" class="cursor-pointer"
:disabled="!isSelected(section.name) && selectedCount >= 5" :disabled="!isSelected(section.id) && selectedCount >= 5"
@click="toggleSection(section.name)" @click="toggleSection(section.id)"
> >
<VCardText class="d-flex align-center ga-3 pa-3"> <VCardText class="d-flex align-center ga-3 pa-3">
<VCheckboxBtn <VCheckboxBtn
:model-value="isSelected(section.name)" :model-value="isSelected(section.id)"
readonly readonly
density="compact" density="compact"
hide-details hide-details
@@ -912,9 +1141,8 @@ async function onSubmit() {
<VIcon <VIcon
v-if="section.icon" v-if="section.icon"
size="20" size="20"
> :icon="section.icon"
{{ section.icon }} />
</VIcon>
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="text-body-1 font-weight-medium"> <div class="text-body-1 font-weight-medium">
@@ -929,12 +1157,12 @@ async function onSubmit() {
</div> </div>
<VChip <VChip
v-if="isSelected(section.name)" v-if="isSelected(section.id)"
size="x-small" size="x-small"
color="primary" color="primary"
variant="elevated" variant="elevated"
> >
#{{ getSelectionPriority(section.name) }} #{{ getSelectionPriority(section.id) }}
</VChip> </VChip>
</VCardText> </VCardText>
</VCard> </VCard>
@@ -959,8 +1187,8 @@ async function onSubmit() {
</p> </p>
</div> </div>
<!-- Step 4: Beschikbaarheid --> <!-- Beschikbaarheid -->
<div v-show="currentStep === 4"> <div v-show="currentStepKind === 'availability'">
<p <p
v-if="registrationData.time_slots.length === 0" v-if="registrationData.time_slots.length === 0"
class="text-body-1 text-medium-emphasis" class="text-body-1 text-medium-emphasis"

View File

@@ -8,18 +8,4 @@ export const step1Schema = z.object({
phone: z.string().max(50).optional().or(z.literal('')), phone: z.string().max(50).optional().or(z.literal('')),
}) })
export const step2Schema = z.object({
tshirt_size: z.enum(['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL']).optional().or(z.literal('')),
first_aid: z.boolean().default(false),
allergies: z.string().max(500).optional().or(z.literal('')),
driving_licence: z.boolean().default(false),
})
export const step3Schema = z.object({
motivation: z.string().max(1000).optional().or(z.literal('')),
motivation_other: z.string().max(500).optional().or(z.literal('')),
})
export const fullRegistrationSchema = step1Schema export const fullRegistrationSchema = step1Schema
.merge(step2Schema)
.merge(step3Schema)

View File

@@ -1,3 +1,33 @@
export type RegistrationFieldType =
| 'text'
| 'textarea'
| 'select'
| 'multiselect'
| 'checkbox'
| 'radio'
| 'boolean'
| 'number'
| 'tag_picker'
export interface RegistrationTagOption {
id: string
name: string
category: string | null
}
export interface RegistrationField {
id: string
label: string
slug: string
field_type: RegistrationFieldType
options: string[] | null
tag_category: string | null
is_required: boolean
section: string | null
help_text: string | null
available_tags?: RegistrationTagOption[]
}
export interface EventRegistrationData { export interface EventRegistrationData {
event: { event: {
id: string id: string
@@ -8,9 +38,12 @@ export interface EventRegistrationData {
registration_banner_url: string | null registration_banner_url: string | null
registration_welcome_text: string | null registration_welcome_text: string | null
registration_logo_url: string | null registration_logo_url: string | null
registration_show_section_preferences: boolean
registration_show_availability: boolean
} }
sections: SectionOption[] sections: SectionOption[]
time_slots: TimeSlotOption[] time_slots: TimeSlotOption[]
registration_fields: RegistrationField[]
} }
export interface SectionOption { export interface SectionOption {
@@ -31,7 +64,7 @@ export interface TimeSlotOption {
} }
export interface SectionPreference { export interface SectionPreference {
section_name: string festival_section_id: string
priority: number priority: number
} }
@@ -41,22 +74,12 @@ export interface VolunteerAvailability {
} }
export interface VolunteerRegistrationForm { export interface VolunteerRegistrationForm {
// Step 1
first_name: string first_name: string
last_name: string last_name: string
date_of_birth: string date_of_birth: string
email: string email: string
phone: string phone: string
// Step 2 field_values?: Record<string, unknown>
tshirt_size: string section_preferences?: SectionPreference[]
first_aid: boolean
allergies: string
driving_licence: boolean
// Step 3
motivation: string
motivation_other: string
// Step 4
section_preferences: SectionPreference[]
// Step 5
availabilities: VolunteerAvailability[] availabilities: VolunteerAvailability[]
} }