From 4df82d835814eb7a5542a2b23c99ceb4b0278ba8 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Mon, 13 Apr 2026 00:17:58 +0200 Subject: [PATCH] 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 --- .../portal/src/pages/register/[eventSlug].vue | 670 ++++++++++++------ apps/portal/src/schemas/registrationSchema.ts | 14 - apps/portal/src/types/registration.ts | 49 +- 3 files changed, 485 insertions(+), 248 deletions(-) diff --git a/apps/portal/src/pages/register/[eventSlug].vue b/apps/portal/src/pages/register/[eventSlug].vue index 3b7a444c..8d47ef8b 100644 --- a/apps/portal/src/pages/register/[eventSlug].vue +++ b/apps/portal/src/pages/register/[eventSlug].vue @@ -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(null) +const fieldFormData = ref>({}) +const fieldErrors = ref>({}) -// 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([]) - -// Step 4: Availability +const selectedSectionIds = ref([]) const selectedTimeSlotIds = ref([]) const timeSlotPreferences = ref>({}) -// 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) }) -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 = { - 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 { - 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 { + 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 | undefined { + const out: Record = {} + 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) { + 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() {
+

+ Deel {{ currentStep + 1 }} van {{ steps.length }} +

{{ steps[currentStep].title }}
@@ -752,133 +918,196 @@ async function onSubmit() {
- +
- - +
- - - + {{ group.section }} +
- -
- - - -
-
- - - - - - -
-
- - -
- - - - - - - + - - +
+ + + + + + + + + + +
+
+ {{ field.label }} * +
+

+ {{ field.help_text }} +

+ +
+ {{ fieldErrors[field.slug] }} +
+
+ +
+
+ {{ field.label }} * +
+

+ {{ field.help_text }} +

+ + + +
+ + + + +
-
-
+ + + +

+ Geen extra vragen voor dit evenement. +

- -
+ +