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 { fullRegistrationSchema } from '@/schemas/registrationSchema'
import type {
RegistrationField,
RegistrationFieldType,
SectionOption,
SectionPreference,
TimeSlotOption,
@@ -32,9 +34,10 @@ const { mutateAsync: submitRegistration, isPending: isSubmitting } = useSubmitRe
const currentStep = ref(0)
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 } = useForm({
const { errors, defineField, validateField, setFieldValue, setFieldError } = useForm({
validationSchema: toTypedSchema(fullRegistrationSchema),
initialValues: {
first_name: '',
@@ -42,12 +45,6 @@ const { errors, defineField, validateField, setFieldValue } = useForm({
email: '',
date_of_birth: '',
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 [email] = defineField('email')
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
watch(() => authStore.user, user => {
@@ -72,36 +63,84 @@ watch(() => authStore.user, user => {
}
}, { immediate: true })
// Step 3: Section preferences (by name, not ID)
const selectedSections = ref<string[]>([])
// Step 4: Availability
const selectedSectionIds = ref<string[]>([])
const selectedTimeSlotIds = ref<string[]>([])
const timeSlotPreferences = ref<Record<string, number>>({})
// Steps definition
const steps = [
{ 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?' },
]
const showSections = computed(() => Boolean(registrationData.value?.event.registration_show_section_preferences))
const showAvailability = computed(() => Boolean(registrationData.value?.event.registration_show_availability))
// Constants
const tshirtSizeItems = [
{ title: 'Geen voorkeur', value: '' },
...['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'].map(s => ({ title: s, value: s })),
]
const steps = computed(() => {
const list = [
{ title: 'Over jou', subtitle: 'Vul je persoonlijke gegevens in' },
{ 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 = [
{ title: 'Gratis festivalpas', value: 'Gratis festivalpas' },
{ title: 'Ervaring opdoen', value: 'Ervaring opdoen' },
{ title: 'Vrienden helpen', value: 'Vrienden helpen' },
{ title: 'CV opbouwen', value: 'CV opbouwen' },
{ title: 'Ik ben gevraagd', value: 'Ik ben gevraagd' },
{ title: 'Anders', value: 'Anders' },
]
return list
})
const sectionsStepIndex = computed(() => (showSections.value ? 2 : -1))
const availabilityStepIndex = computed(() => {
if (!showAvailability.value) return -1
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
const sectionsByCategory = computed(() => {
@@ -116,15 +155,15 @@ const sectionsByCategory = computed(() => {
}, {} as Record<string, SectionOption[]>)
})
function isSelected(sectionName: string) {
return selectedSections.value.includes(sectionName)
function isSelected(sectionId: string) {
return selectedSectionIds.value.includes(sectionId)
}
function getSelectionPriority(sectionName: string) {
return selectedSections.value.indexOf(sectionName) + 1
function getSelectionPriority(sectionId: string) {
return selectedSectionIds.value.indexOf(sectionId) + 1
}
const selectedCount = computed(() => selectedSections.value.length)
const selectedCount = computed(() => selectedSectionIds.value.length)
// Computed
const timeSlotsByDate = computed(() => {
@@ -158,27 +197,108 @@ const formattedDates = computed(() => {
)
})
// Step field mapping for validation (0-based)
type FormField = 'first_name' | 'last_name' | 'date_of_birth' | 'email' | 'phone' | 'tshirt_size' | 'first_aid' | 'allergies' | 'driving_licence' | 'motivation' | 'motivation_other'
const personalFieldKeys = ['first_name', 'last_name', 'date_of_birth', 'email', 'phone'] as const
const stepFields: Record<number, FormField[]> = {
0: ['first_name', 'last_name', 'date_of_birth', 'email', 'phone'],
1: ['tshirt_size', 'first_aid', 'allergies', 'driving_licence'],
2: ['motivation', 'motivation_other'],
function isMultiValueType(t: RegistrationFieldType): boolean {
return t === 'multiselect' || t === 'checkbox' || t === 'tag_picker'
}
// Navigation
async function validateCurrentStep(): Promise<boolean> {
const fields = stepFields[currentStep.value]
if (!fields) return true
const results = await Promise.all(fields.map(f => validateField(f)))
function isEmptyFieldValue(value: unknown, type: RegistrationFieldType): boolean {
if (value === undefined || value === null) return true
if (value === '') return true
if (type === 'number' && (typeof value !== 'number' || Number.isNaN(value))) return true
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() {
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(sectionName: string) {
const idx = selectedSections.value.indexOf(sectionName)
function toggleSection(sectionId: string) {
const idx = selectedSectionIds.value.indexOf(sectionId)
if (idx !== -1) {
selectedSections.value.splice(idx, 1)
selectedSectionIds.value.splice(idx, 1)
}
else if (selectedSections.value.length < 5) {
selectedSections.value.push(sectionName)
else if (selectedSectionIds.value.length < 5) {
selectedSectionIds.value.push(sectionId)
}
}
@@ -240,29 +359,69 @@ function formatTimeRange(start: string, end: string): string {
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() {
submitError.value = null
fieldErrors.value = {}
// Validate steps 0-2
for (let step = 0; step <= 2; step++) {
const fields = stepFields[step]
if (!fields) continue
const results = await Promise.all(fields.map(f => validateField(f)))
const rPersonal = await Promise.all(personalFieldKeys.map(f => validateField(f)))
if (!rPersonal.every(x => x.valid)) {
currentStep.value = 0
if (!results.every(r => r.valid)) {
currentStep.value = step
return
}
if (!validateDynamicFields()) {
currentStep.value = 1
return
}
return
}
if (!registrationData.value) return
const sectionPreferences: SectionPreference[] = selectedSections.value.map((sectionName, index) => ({
section_name: sectionName,
priority: index + 1,
}))
const field_values = buildFieldValuesPayload()
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 => ({
time_slot_id: id,
@@ -275,16 +434,12 @@ async function onSubmit() {
date_of_birth: dateOfBirth.value ?? '',
email: email.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,
}
if (field_values) payload.field_values = field_values
if (section_preferences?.length) payload.section_preferences = section_preferences
try {
await submitRegistration({
eventId: registrationData.value.event.id,
@@ -307,14 +462,22 @@ async function onSubmit() {
const serverErrors = axiosError.response.data?.errors
if (serverErrors) {
for (const field of Object.keys(serverErrors)) {
for (const [step, fields] of Object.entries(stepFields)) {
if (fields.includes(field as FormField)) {
currentStep.value = Number(step)
applyServerValidationErrors(serverErrors)
const keys = Object.keys(serverErrors)
if (keys.some(k => isPersonalFieldKey(k))) {
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.'
@@ -614,6 +777,9 @@ async function onSubmit() {
<VCardText class="pa-8">
<!-- Step header -->
<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">
{{ steps[currentStep].title }}
</h5>
@@ -752,133 +918,196 @@ async function onSubmit() {
</VRow>
</div>
<!-- Step 1: Meer over jou -->
<!-- Step 1: Extra informatie (dynamic registration_fields) -->
<div v-show="currentStep === 1">
<VRow>
<VCol
cols="12"
md="6"
<template
v-for="(group, gi) in groupedRegistrationFields"
:key="gi"
>
<div
v-if="group.section"
class="text-subtitle-2 text-medium-emphasis mt-4 mb-2"
>
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
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>
{{ group.section }}
</div>
<VCol
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>
<VRow>
<VCol
v-if="motivation"
v-for="field in group.fields"
:key="field.id"
cols="12"
:md="field.field_type === 'textarea' || field.field_type === 'checkbox' ? '12' : '6'"
>
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-motivation-other"
>
Toelichting
</label>
<VTextarea
id="volunteer-reg-motivation-other"
v-model="motivationOther"
variant="outlined"
placeholder="Vertel ons meer over je motivatie..."
:error-messages="errors.motivation_other"
:counter="500"
rows="3"
auto-grow
density="comfortable"
hide-details="auto"
/>
<div :id="`reg-field-${field.slug}`">
<VTextField
v-if="field.field_type === 'text'"
v-model="fieldFormData[field.slug] as string"
variant="outlined"
density="comfortable"
hide-details="auto"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
<VTextField
v-else-if="field.field_type === 'number'"
:model-value="numberFieldModel(field.slug)"
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>
</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>
<!-- Step 3: Secties -->
<div v-show="currentStep === 3">
<!-- Secties -->
<div v-show="currentStepKind === 'sections'">
<template
v-for="(sections, category) in sectionsByCategory"
:key="category"
@@ -895,15 +1124,15 @@ async function onSubmit() {
sm="6"
>
<VCard
:variant="isSelected(section.name) ? 'flat' : 'outlined'"
:color="isSelected(section.name) ? 'primary' : undefined"
:variant="isSelected(section.id) ? 'flat' : 'outlined'"
:color="isSelected(section.id) ? 'primary' : undefined"
class="cursor-pointer"
:disabled="!isSelected(section.name) && selectedCount >= 5"
@click="toggleSection(section.name)"
:disabled="!isSelected(section.id) && selectedCount >= 5"
@click="toggleSection(section.id)"
>
<VCardText class="d-flex align-center ga-3 pa-3">
<VCheckboxBtn
:model-value="isSelected(section.name)"
:model-value="isSelected(section.id)"
readonly
density="compact"
hide-details
@@ -912,9 +1141,8 @@ async function onSubmit() {
<VIcon
v-if="section.icon"
size="20"
>
{{ section.icon }}
</VIcon>
:icon="section.icon"
/>
<div class="flex-grow-1">
<div class="text-body-1 font-weight-medium">
@@ -929,12 +1157,12 @@ async function onSubmit() {
</div>
<VChip
v-if="isSelected(section.name)"
v-if="isSelected(section.id)"
size="x-small"
color="primary"
variant="elevated"
>
#{{ getSelectionPriority(section.name) }}
#{{ getSelectionPriority(section.id) }}
</VChip>
</VCardText>
</VCard>
@@ -959,8 +1187,8 @@ async function onSubmit() {
</p>
</div>
<!-- Step 4: Beschikbaarheid -->
<div v-show="currentStep === 4">
<!-- Beschikbaarheid -->
<div v-show="currentStepKind === 'availability'">
<p
v-if="registrationData.time_slots.length === 0"
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('')),
})
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
.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 {
event: {
id: string
@@ -8,9 +38,12 @@ export interface EventRegistrationData {
registration_banner_url: string | null
registration_welcome_text: string | null
registration_logo_url: string | null
registration_show_section_preferences: boolean
registration_show_availability: boolean
}
sections: SectionOption[]
time_slots: TimeSlotOption[]
registration_fields: RegistrationField[]
}
export interface SectionOption {
@@ -31,7 +64,7 @@ export interface TimeSlotOption {
}
export interface SectionPreference {
section_name: string
festival_section_id: string
priority: number
}
@@ -41,22 +74,12 @@ export interface VolunteerAvailability {
}
export interface VolunteerRegistrationForm {
// Step 1
first_name: string
last_name: string
date_of_birth: string
email: string
phone: string
// Step 2
tshirt_size: string
first_aid: boolean
allergies: string
driving_licence: boolean
// Step 3
motivation: string
motivation_other: string
// Step 4
section_preferences: SectionPreference[]
// Step 5
field_values?: Record<string, unknown>
section_preferences?: SectionPreference[]
availabilities: VolunteerAvailability[]
}