Files
crewli/apps/portal/src/components/registration/DynamicRegistrationFields.vue
bert.hausmans e986e4c7eb 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
2026-04-12 23:59:16 +02:00

275 lines
8.9 KiB
Vue

<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>