feat(portal): dynamic volunteer registration fields and conditional steps
Render registration_fields from the API grouped by section, submit field_values and festival_section_id-based section_preferences. Respect event toggles for section preferences and availability; polish layout (header, welcome v-html, section cards, navigation). Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,274 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RegistrationField } from '@/types/registration'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
groups: { section: string | null; fields: RegistrationField[] }[]
|
||||||
|
fieldErrors: Record<string, string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const fieldValues = defineModel<Record<string, unknown>>('fieldValues', { required: true })
|
||||||
|
|
||||||
|
function hint(field: RegistrationField): string | undefined {
|
||||||
|
return field.help_text ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureArray(slug: string): string[] {
|
||||||
|
const v = fieldValues.value[slug]
|
||||||
|
if (Array.isArray(v)) return v.map(String)
|
||||||
|
fieldValues.value[slug] = []
|
||||||
|
|
||||||
|
return fieldValues.value[slug] as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCheckboxOption(slug: string, option: string, checked: boolean | null) {
|
||||||
|
const arr = ensureArray(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)
|
||||||
|
}
|
||||||
|
fieldValues.value[slug] = [...arr]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCheckboxOptionChecked(slug: string, option: string): boolean {
|
||||||
|
const arr = fieldValues.value[slug]
|
||||||
|
if (!Array.isArray(arr)) return false
|
||||||
|
|
||||||
|
return arr.includes(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberModel(slug: string): string {
|
||||||
|
const v = fieldValues.value[slug]
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
if (typeof v === 'number' && Number.isNaN(v)) return ''
|
||||||
|
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNumberInput(slug: string, raw: string) {
|
||||||
|
if (raw === '' || raw === '-') {
|
||||||
|
fieldValues.value[slug] = null
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const n = Number(raw)
|
||||||
|
fieldValues.value[slug] = Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="groups.length === 0">
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||||
|
Er zijn geen extra vragen voor dit evenement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(group, gi) in groups"
|
||||||
|
:key="gi"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<div class="dynamic-section-heading text-subtitle-2 font-weight-medium text-medium-emphasis pb-2 mb-4">
|
||||||
|
{{ group.section?.trim() ? group.section : 'Algemeen' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
v-for="field in group.fields"
|
||||||
|
:key="field.id"
|
||||||
|
cols="12"
|
||||||
|
:md="field.field_type === 'boolean' ? '12' : field.field_type === 'textarea' ? '12' : '6'"
|
||||||
|
>
|
||||||
|
<div :id="`dyn-field-${field.slug}`">
|
||||||
|
<!-- text -->
|
||||||
|
<VTextField
|
||||||
|
v-if="field.field_type === 'text'"
|
||||||
|
v-model="fieldValues[field.slug] as string"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
:label="field.label + (field.is_required ? ' *' : '')"
|
||||||
|
:hint="hint(field)"
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="fieldErrors[field.slug]"
|
||||||
|
:class="{ 'dynamic-field-error': !!fieldErrors[field.slug] }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- number -->
|
||||||
|
<VTextField
|
||||||
|
v-else-if="field.field_type === 'number'"
|
||||||
|
:model-value="numberModel(field.slug)"
|
||||||
|
variant="outlined"
|
||||||
|
type="number"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
:label="field.label + (field.is_required ? ' *' : '')"
|
||||||
|
:hint="hint(field)"
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="fieldErrors[field.slug]"
|
||||||
|
:class="{ 'dynamic-field-error': !!fieldErrors[field.slug] }"
|
||||||
|
@update:model-value="(v: string | number | null) => onNumberInput(field.slug, String(v ?? ''))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- textarea -->
|
||||||
|
<VTextarea
|
||||||
|
v-else-if="field.field_type === 'textarea'"
|
||||||
|
v-model="fieldValues[field.slug] as string"
|
||||||
|
variant="outlined"
|
||||||
|
rows="3"
|
||||||
|
auto-grow
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
:label="field.label + (field.is_required ? ' *' : '')"
|
||||||
|
:hint="hint(field)"
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="fieldErrors[field.slug]"
|
||||||
|
:class="{ 'dynamic-field-error': !!fieldErrors[field.slug] }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- select -->
|
||||||
|
<VSelect
|
||||||
|
v-else-if="field.field_type === 'select'"
|
||||||
|
v-model="fieldValues[field.slug] as string"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
:items="field.options ?? []"
|
||||||
|
:label="field.label + (field.is_required ? ' *' : '')"
|
||||||
|
:hint="hint(field)"
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="fieldErrors[field.slug]"
|
||||||
|
:clearable="!field.is_required"
|
||||||
|
:class="{ 'dynamic-field-error': !!fieldErrors[field.slug] }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- multiselect -->
|
||||||
|
<VSelect
|
||||||
|
v-else-if="field.field_type === 'multiselect'"
|
||||||
|
v-model="fieldValues[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="hint(field)"
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="fieldErrors[field.slug]"
|
||||||
|
:class="{ 'dynamic-field-error': !!fieldErrors[field.slug] }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- checkbox (one per option) -->
|
||||||
|
<div
|
||||||
|
v-else-if="field.field_type === 'checkbox'"
|
||||||
|
class="rounded-lg pa-4 border border-opacity-100"
|
||||||
|
style="border-color: rgba(var(--v-border-color), var(--v-border-opacity));"
|
||||||
|
:class="{ 'dynamic-field-error': !!fieldErrors[field.slug] }"
|
||||||
|
>
|
||||||
|
<div class="text-body-2 font-weight-medium mb-2">
|
||||||
|
{{ field.label }}<span v-if="field.is_required" class="text-error"> *</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="hint(field)"
|
||||||
|
class="text-caption text-medium-emphasis mb-3"
|
||||||
|
>
|
||||||
|
{{ hint(field) }}
|
||||||
|
</p>
|
||||||
|
<div class="d-flex flex-column ga-1">
|
||||||
|
<VCheckbox
|
||||||
|
v-for="opt in (field.options ?? [])"
|
||||||
|
:key="opt"
|
||||||
|
:model-value="isCheckboxOptionChecked(field.slug, opt)"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
:label="opt"
|
||||||
|
@update:model-value="(v: boolean | null) => toggleCheckboxOption(field.slug, opt, v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="fieldErrors[field.slug]"
|
||||||
|
class="text-caption text-error mt-2"
|
||||||
|
>
|
||||||
|
{{ fieldErrors[field.slug] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- radio -->
|
||||||
|
<div v-else-if="field.field_type === 'radio'">
|
||||||
|
<div class="text-body-2 font-weight-medium mb-1">
|
||||||
|
{{ field.label }}<span v-if="field.is_required" class="text-error"> *</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="hint(field)"
|
||||||
|
class="text-caption text-medium-emphasis mb-2"
|
||||||
|
>
|
||||||
|
{{ hint(field) }}
|
||||||
|
</p>
|
||||||
|
<VRadioGroup
|
||||||
|
v-model="fieldValues[field.slug] as string"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
:error-messages="fieldErrors[field.slug]"
|
||||||
|
:class="{ 'dynamic-field-error': !!fieldErrors[field.slug] }"
|
||||||
|
>
|
||||||
|
<VRadio
|
||||||
|
v-for="opt in (field.options ?? [])"
|
||||||
|
:key="opt"
|
||||||
|
:label="opt"
|
||||||
|
:value="opt"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</VRadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- boolean -->
|
||||||
|
<VSwitch
|
||||||
|
v-else-if="field.field_type === 'boolean'"
|
||||||
|
v-model="fieldValues[field.slug] as boolean"
|
||||||
|
inset
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
:label="field.label + (field.is_required ? ' *' : '')"
|
||||||
|
:hint="hint(field)"
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="fieldErrors[field.slug]"
|
||||||
|
:class="{ 'dynamic-field-error': !!fieldErrors[field.slug] }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- tag_picker -->
|
||||||
|
<VAutocomplete
|
||||||
|
v-else-if="field.field_type === 'tag_picker'"
|
||||||
|
v-model="fieldValues[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="hint(field)"
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="fieldErrors[field.slug]"
|
||||||
|
:class="{ 'dynamic-field-error': !!fieldErrors[field.slug] }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dynamic-section-heading {
|
||||||
|
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const step1Schema = z.object({
|
/** Fixed fields for step "Over jou" (portal volunteer registration). */
|
||||||
|
export const personalStepSchema = z.object({
|
||||||
first_name: z.string().min(1, 'Voornaam is verplicht').max(255),
|
first_name: z.string().min(1, 'Voornaam is verplicht').max(255),
|
||||||
last_name: z.string().min(1, 'Achternaam is verplicht').max(255),
|
last_name: z.string().min(1, 'Achternaam is verplicht').max(255),
|
||||||
email: z.string().min(1, 'E-mailadres is verplicht').email('Ongeldig e-mailadres').max(255),
|
email: z.string().min(1, 'E-mailadres is verplicht').email('Ongeldig e-mailadres').max(255),
|
||||||
@@ -8,18 +9,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({
|
export const fullRegistrationSchema = personalStepSchema
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -30,33 +63,25 @@ export interface TimeSlotOption {
|
|||||||
duration_hours: number
|
duration_hours: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SectionPreference {
|
export interface SectionPreferencePayload {
|
||||||
section_name: string
|
festival_section_id: string
|
||||||
priority: number
|
priority: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VolunteerAvailability {
|
export interface VolunteerAvailabilityPayload {
|
||||||
time_slot_id: string
|
time_slot_id: string
|
||||||
preference_level: number
|
preference_level?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FieldValuePayload = string | number | boolean | string[] | null
|
||||||
|
|
||||||
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, FieldValuePayload>
|
||||||
tshirt_size: string
|
section_preferences?: SectionPreferencePayload[]
|
||||||
first_aid: boolean
|
availabilities?: VolunteerAvailabilityPayload[]
|
||||||
allergies: string
|
|
||||||
driving_licence: boolean
|
|
||||||
// Step 3
|
|
||||||
motivation: string
|
|
||||||
motivation_other: string
|
|
||||||
// Step 4
|
|
||||||
section_preferences: SectionPreference[]
|
|
||||||
// Step 5
|
|
||||||
availabilities: VolunteerAvailability[]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user