feat(portal): public-form component architecture

Replace monolithic register/[eventSlug].vue with composable field
renderer, conditional-logic engine, stepper, and per-field components
driven by Form Builder schema. Adds flatpickr for date fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 17:20:59 +02:00
parent 0cbdad70cd
commit 4074dce402
29 changed files with 2622 additions and 1574 deletions

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'blur'): void
}>()
const model = computed({
get: () => Boolean(props.modelValue),
set: (v: boolean) => emit('update:modelValue', v),
})
</script>
<template>
<div>
<VSwitch
v-model="model"
inset
color="primary"
density="comfortable"
hide-details="auto"
:label="field.label + (field.is_required ? ' *' : '')"
:error-messages="errorMessages"
@update:model-value="emit('blur')"
/>
<p
v-if="field.help_text"
class="text-caption text-medium-emphasis mt-1 mb-0"
>
{{ field.help_text }}
</p>
</div>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { FieldOption, PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField, runValidators } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string[]): void
(e: 'blur'): void
}>()
interface NormalizedOption {
label: string
description: string | null
value: string
}
function normalize(opt: FieldOption): NormalizedOption {
if (typeof opt === 'string') return { label: opt, description: null, value: opt }
const value = opt.value != null ? String(opt.value) : opt.label
return { label: opt.label, description: opt.description ?? null, value }
}
const options = computed<NormalizedOption[]>(() => (props.field.options ?? []).map(normalize))
const selected = computed<string[]>(() =>
Array.isArray(props.modelValue) ? (props.modelValue as unknown[]).map(String) : [],
)
const rules = computed(() => getValidatorsForField(props.field))
const clientError = computed(() => {
const res = runValidators(rules.value, selected.value)
return res === true ? null : res
})
const displayedErrors = computed(() => {
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
if (clientError.value) return [clientError.value]
return []
})
function isChecked(value: string): boolean {
return selected.value.includes(value)
}
function toggle(value: string, checked: boolean | null): void {
const next = [...selected.value]
const idx = next.indexOf(value)
if (checked) {
if (idx === -1) next.push(value)
}
else if (idx !== -1) {
next.splice(idx, 1)
}
emit('update:modelValue', next)
emit('blur')
}
</script>
<template>
<div>
<div class="text-body-2 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 options"
:key="opt.value"
:model-value="isChecked(opt.value)"
density="comfortable"
hide-details
@update:model-value="(v: boolean | null) => toggle(opt.value, v)"
>
<template #label>
<div>
<span class="text-body-1">{{ opt.label }}</span>
<p
v-if="opt.description"
class="text-caption text-medium-emphasis mt-1 mb-0"
>
{{ opt.description }}
</p>
</div>
</template>
</VCheckbox>
<div
v-if="displayedErrors.length"
class="text-caption text-error mt-1"
>
{{ displayedErrors[0] }}
</div>
</div>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'blur'): void
}>()
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
get: () => (props.modelValue ?? '') as string,
set: (v: string) => emit('update:modelValue', v),
})
const config = { dateFormat: 'Y-m-d', allowInput: true }
</script>
<template>
<AppDateTimePicker
v-model="model"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
prepend-inner-icon="tabler-calendar"
:config="config"
:rules="rules"
:error-messages="errorMessages"
@blur="emit('blur')"
/>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'blur'): void
}>()
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
get: () => (props.modelValue ?? '') as string,
set: (v: string) => emit('update:modelValue', v),
})
</script>
<template>
<AppTextField
v-model="model"
type="email"
inputmode="email"
autocomplete="email"
:label="field.label"
:hint="field.help_text ?? undefined"
persistent-hint
prepend-inner-icon="tabler-mail"
:rules="rules"
:error-messages="errorMessages"
:required="field.is_required"
@blur="emit('blur')"
/>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
defineProps<{
field: PublicFormField
}>()
</script>
<template>
<div>
<h3 class="text-h6 mt-6 mb-2">
{{ field.label }}
</h3>
<p
v-if="field.help_text"
class="text-body-2 text-medium-emphasis mb-4"
>
{{ field.help_text }}
</p>
<VDivider class="mb-2" />
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { FieldOption, PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string[]): void
(e: 'blur'): void
}>()
interface NormalizedOption {
title: string
description?: string | null
value: string
}
function normalize(opt: FieldOption): NormalizedOption {
if (typeof opt === 'string') return { title: opt, value: opt }
const value = opt.value != null ? String(opt.value) : opt.label
return { title: opt.label, description: opt.description ?? null, value }
}
const items = computed<NormalizedOption[]>(() => (props.field.options ?? []).map(normalize))
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue as string[] : []),
set: (v: string[]) => emit('update:modelValue', v),
})
</script>
<template>
<AppSelect
v-model="model"
multiple
chips
closable-chips
:items="items"
item-title="title"
item-value="value"
:label="field.label"
:hint="field.help_text ?? undefined"
persistent-hint
:rules="rules"
:error-messages="errorMessages"
:required="field.is_required"
@update:model-value="emit('blur')"
@blur="emit('blur')"
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps">
<template
v-if="item.raw.description"
#subtitle
>
{{ item.raw.description }}
</template>
</VListItem>
</template>
</AppSelect>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: number | null): void
(e: 'blur'): void
}>()
const rules = computed(() => getValidatorsForField(props.field))
const inputValue = computed<string>(() => {
const v = props.modelValue
if (v === null || v === undefined) return ''
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
return String(v)
})
function onUpdate(raw: string | number | null) {
const s = raw === null || raw === undefined ? '' : String(raw)
if (s === '' || s === '-') {
emit('update:modelValue', null)
return
}
const n = Number(s)
emit('update:modelValue', Number.isFinite(n) ? n : null)
}
</script>
<template>
<AppTextField
:model-value="inputValue"
type="number"
inputmode="decimal"
:label="field.label"
:hint="field.help_text ?? undefined"
persistent-hint
:min="field.validation_rules?.min ?? undefined"
:max="field.validation_rules?.max ?? undefined"
:rules="rules"
:error-messages="errorMessages"
:required="field.is_required"
@update:model-value="onUpdate"
@blur="emit('blur')"
/>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
defineProps<{
field: PublicFormField
}>()
</script>
<template>
<p class="text-body-1 text-medium-emphasis mb-4">
{{ field.label }}
</p>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'blur'): void
}>()
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
get: () => (props.modelValue ?? '') as string,
set: (v: string) => emit('update:modelValue', v),
})
</script>
<template>
<AppTextField
v-model="model"
type="tel"
inputmode="tel"
autocomplete="tel"
placeholder="+31 6 12345678"
:label="field.label"
:hint="field.help_text ?? undefined"
persistent-hint
prepend-inner-icon="tabler-phone"
:rules="rules"
:error-messages="errorMessages"
:required="field.is_required"
@blur="emit('blur')"
/>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { FieldOption, PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'blur'): void
}>()
interface NormalizedOption {
label: string
description?: string | null
value: string
}
function normalize(opt: FieldOption): NormalizedOption {
if (typeof opt === 'string') return { label: opt, value: opt }
const base = { label: opt.label, description: opt.description ?? null }
const value = opt.value != null ? String(opt.value) : opt.label
return { ...base, value }
}
const options = computed<NormalizedOption[]>(() =>
(props.field.options ?? []).map(normalize),
)
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
get: () => (props.modelValue ?? '') as string,
set: (v: string) => {
emit('update:modelValue', v)
emit('blur')
},
})
</script>
<template>
<div>
<div class="text-body-2 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="model"
density="comfortable"
:rules="rules"
:error-messages="errorMessages"
hide-details="auto"
>
<VRadio
v-for="opt in options"
:key="opt.value"
:value="opt.value"
>
<template #label>
<div>
<span class="text-body-1">{{ opt.label }}</span>
<p
v-if="opt.description"
class="text-disabled text-caption mt-1 mb-0"
>
{{ opt.description }}
</p>
</div>
</template>
</VRadio>
</VRadioGroup>
</div>
</template>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import FieldBoolean from './FieldBoolean.vue'
import FieldCheckboxList from './FieldCheckboxList.vue'
import FieldDate from './FieldDate.vue'
import FieldEmail from './FieldEmail.vue'
import FieldHeading from './FieldHeading.vue'
import FieldMultiselect from './FieldMultiselect.vue'
import FieldNumber from './FieldNumber.vue'
import FieldParagraph from './FieldParagraph.vue'
import FieldPhone from './FieldPhone.vue'
import FieldRadio from './FieldRadio.vue'
import FieldSelect from './FieldSelect.vue'
import FieldText from './FieldText.vue'
import FieldTextarea from './FieldTextarea.vue'
import FieldUrl from './FieldUrl.vue'
import { evaluateConditionalLogic } from '@/composables/useConditionalLogic'
import { FormFieldType } from '@/types/formBuilder'
import type { FormFieldDisplayWidth, FormValues, PublicFormField } from '@/types/formBuilder'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
allValues: FormValues
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: unknown): void
(e: 'blur'): void
}>()
const visible = computed(() => evaluateConditionalLogic(props.field.conditional_logic, props.allValues))
function colsFor(width: FormFieldDisplayWidth): number {
switch (width) {
case 'half': return 6
case 'third': return 4
case 'quarter': return 3
case 'full':
default:
return 12
}
}
const smCols = computed(() => colsFor(props.field.display_width))
const isStubbed = computed(() =>
props.field.field_type === FormFieldType.TAG_PICKER
|| props.field.field_type === FormFieldType.AVAILABILITY_PICKER
|| props.field.field_type === FormFieldType.SECTION_PRIORITY
|| props.field.field_type === FormFieldType.FILE_UPLOAD
|| props.field.field_type === FormFieldType.IMAGE_UPLOAD
|| props.field.field_type === FormFieldType.SIGNATURE
|| props.field.field_type === FormFieldType.TABLE_ROWS
|| props.field.field_type === FormFieldType.DATETIME,
)
function onUpdate(v: unknown): void {
emit('update:modelValue', v)
}
function onBlur(): void {
emit('blur')
}
</script>
<template>
<VCol
v-if="visible"
cols="12"
:sm="smCols"
>
<div :id="`ff-${field.slug}`">
<template v-if="isStubbed">
<VAlert
type="info"
variant="tonal"
density="comfortable"
>
<div class="text-subtitle-2 mb-1">
{{ field.label }}
</div>
<div class="text-body-2">
Dit veldtype wordt binnenkort ondersteund.
</div>
</VAlert>
</template>
<FieldText
v-else-if="field.field_type === FormFieldType.TEXT"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldTextarea
v-else-if="field.field_type === FormFieldType.TEXTAREA"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldEmail
v-else-if="field.field_type === FormFieldType.EMAIL"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldPhone
v-else-if="field.field_type === FormFieldType.PHONE"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldNumber
v-else-if="field.field_type === FormFieldType.NUMBER"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldDate
v-else-if="field.field_type === FormFieldType.DATE"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldBoolean
v-else-if="field.field_type === FormFieldType.BOOLEAN"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldRadio
v-else-if="field.field_type === FormFieldType.RADIO"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldSelect
v-else-if="field.field_type === FormFieldType.SELECT"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldMultiselect
v-else-if="field.field_type === FormFieldType.MULTISELECT"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldCheckboxList
v-else-if="field.field_type === FormFieldType.CHECKBOX_LIST"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldHeading
v-else-if="field.field_type === FormFieldType.HEADING"
:field="field"
/>
<FieldParagraph
v-else-if="field.field_type === FormFieldType.PARAGRAPH"
:field="field"
/>
<FieldUrl
v-else-if="field.field_type === FormFieldType.URL"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
</div>
</VCol>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { FieldOption, PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string | null): void
(e: 'blur'): void
}>()
interface NormalizedOption {
title: string
description?: string | null
value: string
}
function normalize(opt: FieldOption): NormalizedOption {
if (typeof opt === 'string') return { title: opt, value: opt }
const value = opt.value != null ? String(opt.value) : opt.label
return { title: opt.label, description: opt.description ?? null, value }
}
const items = computed<NormalizedOption[]>(() => (props.field.options ?? []).map(normalize))
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
get: () => (props.modelValue ?? null) as string | null,
set: (v: string | null) => emit('update:modelValue', v),
})
</script>
<template>
<AppSelect
v-model="model"
:items="items"
item-title="title"
item-value="value"
:label="field.label"
:hint="field.help_text ?? undefined"
persistent-hint
:clearable="!field.is_required"
:rules="rules"
:error-messages="errorMessages"
:required="field.is_required"
@update:model-value="emit('blur')"
@blur="emit('blur')"
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps">
<template
v-if="item.raw.description"
#subtitle
>
{{ item.raw.description }}
</template>
</VListItem>
</template>
</AppSelect>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'blur'): void
}>()
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
get: () => (props.modelValue ?? '') as string,
set: (v: string) => emit('update:modelValue', v),
})
</script>
<template>
<AppTextField
v-model="model"
:label="field.label"
:hint="field.help_text ?? undefined"
persistent-hint
:rules="rules"
:error-messages="errorMessages"
:required="field.is_required"
:maxlength="field.validation_rules?.max ?? undefined"
@blur="emit('blur')"
/>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'blur'): void
}>()
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
get: () => (props.modelValue ?? '') as string,
set: (v: string) => emit('update:modelValue', v),
})
</script>
<template>
<AppTextarea
v-model="model"
:label="field.label"
:hint="field.help_text ?? undefined"
persistent-hint
:rows="4"
auto-grow
:rules="rules"
:error-messages="errorMessages"
:required="field.is_required"
:maxlength="field.validation_rules?.max ?? undefined"
@blur="emit('blur')"
/>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { PublicFormField } from '@/types/formBuilder'
import { getValidatorsForField } from '@/utils/formValidation'
const props = defineProps<{
field: PublicFormField
modelValue: unknown
errorMessages?: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'blur'): void
}>()
const rules = computed(() => getValidatorsForField(props.field))
const model = computed({
get: () => (props.modelValue ?? '') as string,
set: (v: string) => emit('update:modelValue', v),
})
</script>
<template>
<AppTextField
v-model="model"
type="url"
inputmode="url"
placeholder="https://..."
:label="field.label"
:hint="field.help_text ?? undefined"
persistent-hint
prepend-inner-icon="tabler-link"
:rules="rules"
:error-messages="errorMessages"
:required="field.is_required"
@blur="emit('blur')"
/>
</template>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import type { FormStep } from '@/composables/useFormSteps'
import { FormFieldType } from '@/types/formBuilder'
import type { FormValues, PublicFormField } from '@/types/formBuilder'
const props = defineProps<{
steps: FormStep[]
values: FormValues
submitterName?: string
submitterEmail?: string
}>()
function displayValue(field: PublicFormField): string {
const v = props.values[field.slug]
if (v === null || v === undefined || v === '') return '—'
if (Array.isArray(v)) return v.length > 0 ? v.map(String).join(', ') : '—'
if (typeof v === 'boolean') return v ? 'Ja' : 'Nee'
return String(v)
}
function isAnswerable(field: PublicFormField): boolean {
return field.field_type !== FormFieldType.HEADING
&& field.field_type !== FormFieldType.PARAGRAPH
}
function answerableFields(step: FormStep): PublicFormField[] {
return step.fields.filter(isAnswerable)
}
</script>
<template>
<div class="d-flex justify-center pa-4">
<VCard
flat
:max-width="720"
class="w-100"
>
<VCardText class="text-center pa-8">
<VIcon
icon="tabler-check"
color="success"
size="64"
class="mb-4"
/>
<h2 class="text-h4 mb-2">
Bedankt voor je inzending!
</h2>
<p class="text-body-1 text-medium-emphasis mb-0">
We nemen zo snel mogelijk contact op.
</p>
</VCardText>
<VDivider />
<VCardText class="pa-6">
<h3 class="text-subtitle-1 font-weight-medium mb-3">
Contactgegevens
</h3>
<VRow>
<VCol
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
Naam
</p>
<p class="text-body-2 mb-0">
{{ submitterName || '—' }}
</p>
</VCol>
<VCol
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
E-mailadres
</p>
<p class="text-body-2 mb-0">
{{ submitterEmail || '—' }}
</p>
</VCol>
</VRow>
<template
v-for="step in steps"
:key="step.key"
>
<template v-if="step.kind !== 'submitter' && step.kind !== 'review' && answerableFields(step).length > 0">
<VDivider class="my-5" />
<h3 class="text-subtitle-1 font-weight-medium mb-3">
{{ step.title }}
</h3>
<VRow>
<VCol
v-for="field in answerableFields(step)"
:key="field.id"
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
{{ field.label }}
</p>
<p class="text-body-2 mb-0">
{{ displayValue(field) }}
</p>
</VCol>
</VRow>
</template>
</template>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { FormErrorCode } from '@/types/formBuilder'
const props = defineProps<{
errorCode?: FormErrorCode | string
message?: string
showRetry?: boolean
}>()
const emit = defineEmits<{
(e: 'retry'): void
}>()
interface Copy {
heading: string
body: string
icon: string
iconColor: string
terminal: boolean
}
const COPY: Record<string, Copy> = {
TOKEN_EXPIRED: {
heading: 'Deze link is verlopen',
body: 'Deze link is verlopen. Vraag de organisatie om een nieuwe.',
icon: 'tabler-clock-off',
iconColor: 'warning',
terminal: true,
},
TOKEN_REVOKED: {
heading: 'Deze link is ingetrokken',
body: 'Deze link is ingetrokken. Vraag de organisatie om een nieuwe.',
icon: 'tabler-lock',
iconColor: 'warning',
terminal: true,
},
SCHEMA_UNPUBLISHED: {
heading: 'Formulier niet open',
body: 'Dit formulier is op dit moment niet open voor inzendingen.',
icon: 'tabler-file-off',
iconColor: 'warning',
terminal: true,
},
SCHEMA_NOT_FOUND: {
heading: 'Formulier niet gevonden',
body: 'Dit formulier bestaat niet.',
icon: 'tabler-alert-circle',
iconColor: 'error',
terminal: true,
},
SUBMISSION_ALREADY_SUBMITTED: {
heading: 'Al verstuurd',
body: 'Je hebt dit formulier al verstuurd. Bedankt!',
icon: 'tabler-check',
iconColor: 'success',
terminal: true,
},
RATE_LIMITED: {
heading: 'Even geduld',
body: 'Te veel verzoeken. Probeer het over een minuut opnieuw.',
icon: 'tabler-hourglass',
iconColor: 'warning',
terminal: false,
},
}
const DEFAULT_COPY: Copy = {
heading: 'Er ging iets mis',
body: 'Er ging iets mis. Probeer het opnieuw of neem contact op met de organisatie.',
icon: 'tabler-alert-circle',
iconColor: 'error',
terminal: false,
}
const copy = computed<Copy>(() => {
const code = props.errorCode
if (code && code in COPY) return COPY[code]
return DEFAULT_COPY
})
const bodyText = computed(() => props.message ?? copy.value.body)
const canRetry = computed(() => props.showRetry !== false && !copy.value.terminal)
</script>
<template>
<div
class="d-flex align-center justify-center pa-4"
style="min-block-size: 60vh;"
>
<VCard
flat
:max-width="480"
class="text-center pa-8"
>
<VIcon
:icon="copy.icon"
:color="copy.iconColor"
size="64"
class="mb-4"
/>
<h2 class="text-h5 mb-2">
{{ copy.heading }}
</h2>
<p class="text-body-1 text-medium-emphasis mb-6">
{{ bodyText }}
</p>
<VBtn
v-if="canRetry"
variant="tonal"
color="primary"
@click="emit('retry')"
>
Opnieuw proberen
</VBtn>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import type { FormStep } from '@/composables/useFormSteps'
const props = defineProps<{
steps: FormStep[]
currentStep: number
isActiveStepValid: boolean
}>()
const emit = defineEmits<{
(e: 'update:currentStep', v: number): void
}>()
const { mdAndUp } = useDisplay()
const direction = computed<'horizontal' | 'vertical'>(() => (mdAndUp.value ? 'horizontal' : 'vertical'))
const items = computed(() => props.steps.map(s => ({
title: s.title,
subtitle: s.subtitle,
})))
function go(value: number): void {
if (value > props.currentStep && !props.isActiveStepValid) return
if (value < 0 || value >= props.steps.length) return
emit('update:currentStep', value)
}
</script>
<template>
<AppStepper
:items="items"
:current-step="currentStep"
:direction="direction"
:is-active-step-valid="isActiveStepValid"
align="start"
@update:current-step="go"
/>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { emailValidator, requiredValidator } from '@core/utils/validators'
const props = defineProps<{
name: string
email: string
errors?: { name?: string; email?: string }
}>()
const emit = defineEmits<{
(e: 'update:name', v: string): void
(e: 'update:email', v: string): void
(e: 'blur'): void
}>()
const nameModel = computed({
get: () => props.name,
set: v => emit('update:name', v),
})
const emailModel = computed({
get: () => props.email,
set: v => emit('update:email', v),
})
const nameRules = [
(v: unknown) => (requiredValidator(v) === true ? true : 'Vul je naam in.'),
(v: unknown) => (v === null || v === undefined || String(v).length <= 150 ? true : 'Maximaal 150 tekens.'),
]
const emailRules = [
(v: unknown) => (requiredValidator(v) === true ? true : 'Vul je e-mailadres in.'),
(v: unknown) => (emailValidator(v) === true ? true : 'Vul een geldig e-mailadres in.'),
(v: unknown) => (v === null || v === undefined || String(v).length <= 255 ? true : 'Maximaal 255 tekens.'),
]
const nameError = computed(() => props.errors?.name ? [props.errors.name] : [])
const emailError = computed(() => props.errors?.email ? [props.errors.email] : [])
</script>
<template>
<VRow>
<VCol
cols="12"
sm="6"
>
<AppTextField
v-model="nameModel"
label="Naam *"
placeholder="Voor- en achternaam"
autocomplete="name"
:rules="nameRules"
:error-messages="nameError"
:maxlength="150"
@blur="emit('blur')"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<AppTextField
v-model="emailModel"
type="email"
inputmode="email"
autocomplete="email"
label="E-mailadres *"
placeholder="je@email.nl"
prepend-inner-icon="tabler-mail"
:rules="emailRules"
:error-messages="emailError"
:maxlength="255"
@blur="emit('blur')"
/>
</VCol>
<VCol cols="12">
<p class="text-caption text-medium-emphasis mb-0">
We gebruiken je gegevens alleen om contact op te nemen over deze aanvraag.
</p>
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,110 @@
import { useMutation, useQuery } from '@tanstack/vue-query'
import type { AxiosError } from 'axios'
import type { MaybeRefOrGetter } from 'vue'
import { apiClient } from '@/lib/axios'
import type {
PublicFormErrorBody,
PublicFormSchema,
PublicFormSubmission,
SaveDraftBody,
StartDraftBody,
SubmitBody,
} from '@/types/formBuilder'
interface ApiResponse<T> {
data: T
message?: string
success?: boolean
}
/**
* The backend standardises public-form errors as
* { message, code?, errors? }
* See api/app/Exceptions/FormBuilder/PublicFormApiException.php.
*/
export type PublicFormAxiosError = AxiosError<PublicFormErrorBody>
const TERMINAL_STATUSES = new Set([404, 410, 409])
export function useFetchPublicFormSchema(token: MaybeRefOrGetter<string | null | undefined>) {
return useQuery({
queryKey: ['public-form-schema', () => toValue(token)],
queryFn: async (): Promise<PublicFormSchema> => {
const t = toValue(token)
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.get<ApiResponse<PublicFormSchema>>(`/public/forms/${t}`)
return data.data
},
enabled: () => !!toValue(token),
retry: (failureCount, error) => {
const status = (error as PublicFormAxiosError | undefined)?.response?.status
if (status && TERMINAL_STATUSES.has(status)) return false
return failureCount < 1
},
staleTime: 1000 * 60 * 5,
})
}
export function useCreateFormDraft(token: MaybeRefOrGetter<string | null | undefined>) {
return useMutation({
mutationFn: async (body: StartDraftBody): Promise<PublicFormSubmission> => {
const t = toValue(token)
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.post<ApiResponse<PublicFormSubmission>>(
`/public/forms/${t}/submissions`,
body,
)
return data.data
},
})
}
export function useSaveFormDraft(token: MaybeRefOrGetter<string | null | undefined>) {
return useMutation({
mutationFn: async ({ submissionId, body }: { submissionId: string; body: SaveDraftBody }): Promise<PublicFormSubmission> => {
const t = toValue(token)
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.put<ApiResponse<PublicFormSubmission>>(
`/public/forms/${t}/submissions/${submissionId}`,
body,
)
return data.data
},
})
}
export function useSubmitForm(token: MaybeRefOrGetter<string | null | undefined>) {
return useMutation({
mutationFn: async ({ submissionId, body }: { submissionId: string; body: SubmitBody }): Promise<PublicFormSubmission> => {
const t = toValue(token)
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.post<ApiResponse<PublicFormSubmission>>(
`/public/forms/${t}/submissions/${submissionId}/submit`,
body,
)
return data.data
},
})
}
export function extractErrorBody(err: unknown): PublicFormErrorBody | null {
const axiosErr = err as PublicFormAxiosError | undefined
const body = axiosErr?.response?.data
if (body && typeof body === 'object' && 'message' in body) return body as PublicFormErrorBody
return null
}
export function extractRetryAfterSeconds(err: unknown): number | null {
const axiosErr = err as PublicFormAxiosError | undefined
const raw = axiosErr?.response?.headers?.['retry-after']
if (!raw) return null
const n = Number(raw)
return Number.isFinite(n) && n >= 0 ? n : null
}

View File

@@ -0,0 +1,127 @@
import type {
ConditionalGroup,
ConditionalLogic,
ConditionalOperator,
ConditionalRule,
FormValues,
} from '@/types/formBuilder'
function isEmptyValue(v: unknown): boolean {
if (v === null || v === undefined) return true
if (typeof v === 'string') return v.length === 0
if (Array.isArray(v)) return v.length === 0
return false
}
function toComparable(v: unknown): string | number | boolean {
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v
return String(v ?? '')
}
function evaluateRule(rule: ConditionalRule, values: FormValues): boolean {
const op = rule.operator as ConditionalOperator
const actual = values[rule.field_slug]
const expected = rule.value
switch (op) {
case 'empty':
return isEmptyValue(actual)
case 'not_empty':
return !isEmptyValue(actual)
case 'equals':
if (isEmptyValue(actual) && !isEmptyValue(expected)) return false
return toComparable(actual) === toComparable(expected)
case 'not_equals':
if (isEmptyValue(actual) && !isEmptyValue(expected)) return true
return toComparable(actual) !== toComparable(expected)
case 'contains': {
if (isEmptyValue(actual)) return false
if (Array.isArray(actual)) return actual.map(toComparable).includes(toComparable(expected))
return String(actual).includes(String(expected ?? ''))
}
case 'not_contains': {
if (isEmptyValue(actual)) return true
if (Array.isArray(actual)) return !actual.map(toComparable).includes(toComparable(expected))
return !String(actual).includes(String(expected ?? ''))
}
case 'in': {
if (isEmptyValue(actual)) return false
if (!Array.isArray(expected)) return false
const exp = expected.map(toComparable)
if (Array.isArray(actual)) return actual.some(a => exp.includes(toComparable(a)))
return exp.includes(toComparable(actual))
}
case 'not_in': {
if (isEmptyValue(actual)) return true
if (!Array.isArray(expected)) return true
const exp = expected.map(toComparable)
if (Array.isArray(actual)) return !actual.some(a => exp.includes(toComparable(a)))
return !exp.includes(toComparable(actual))
}
case 'greater_than': {
if (isEmptyValue(actual)) return false
const a = Number(actual)
const e = Number(expected)
if (Number.isNaN(a) || Number.isNaN(e)) return false
return a > e
}
case 'less_than': {
if (isEmptyValue(actual)) return false
const a = Number(actual)
const e = Number(expected)
if (Number.isNaN(a) || Number.isNaN(e)) return false
return a < e
}
default:
return true
}
}
function isGroup(node: ConditionalRule | ConditionalGroup): node is ConditionalGroup {
return typeof node === 'object' && node !== null && (('all' in node) || ('any' in node))
}
function evaluateGroup(group: ConditionalGroup, values: FormValues): boolean {
if (Array.isArray(group.all) && group.all.length > 0) {
for (const node of group.all) {
const ok = isGroup(node) ? evaluateGroup(node, values) : evaluateRule(node, values)
if (!ok) return false
}
return true
}
if (Array.isArray(group.any) && group.any.length > 0) {
for (const node of group.any) {
const ok = isGroup(node) ? evaluateGroup(node, values) : evaluateRule(node, values)
if (ok) return true
}
return false
}
return true
}
/**
* Evaluate a conditional logic block against the current form values.
* Returns true when the field/group should be visible; defaults to true
* when the logic is absent or malformed.
*/
export function evaluateConditionalLogic(
logic: ConditionalLogic | null | undefined,
values: FormValues,
): boolean {
if (!logic || !logic.show_when) return true
return evaluateGroup(logic.show_when, values)
}

View File

@@ -0,0 +1,138 @@
import { computed } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import { evaluateConditionalLogic } from '@/composables/useConditionalLogic'
import { FormFieldType } from '@/types/formBuilder'
import type { FormValues, PublicFormField, PublicFormSchema } from '@/types/formBuilder'
import { isFieldValueEmpty } from '@/utils/formValidation'
export type StepKind = 'submitter' | 'section' | 'heading_group' | 'flat' | 'review'
export interface FormStep {
key: string
kind: StepKind
title: string
subtitle?: string
fields: PublicFormField[]
}
function partitionByHeading(fields: PublicFormField[]): FormStep[] {
if (fields.length === 0) return []
const out: FormStep[] = []
let current: FormStep | null = null
let index = 0
for (const field of fields) {
if (field.field_type === FormFieldType.HEADING) {
current = {
key: `heading-${field.id}`,
kind: 'heading_group',
title: field.label,
fields: [field],
}
out.push(current)
index++
continue
}
if (!current) {
current = {
key: `group-${index}`,
kind: 'heading_group',
title: 'Vragen',
fields: [],
}
out.push(current)
index++
}
current.fields.push(field)
}
return out
}
export function useFormSteps(schema: Ref<PublicFormSchema | null | undefined>): ComputedRef<FormStep[]> {
return computed<FormStep[]>(() => {
const s = schema.value
const submitterStep: FormStep = {
key: 'submitter',
kind: 'submitter',
title: 'Contactgegevens',
subtitle: 'Zo kunnen we contact met je opnemen',
fields: [],
}
const reviewStep: FormStep = {
key: 'review',
kind: 'review',
title: 'Controleer en versturen',
subtitle: 'Check je antwoorden en verstuur het formulier',
fields: [],
}
if (!s) return [submitterStep, reviewStep]
const sorted = [...s.fields].sort((a, b) => a.sort_order - b.sort_order)
const steps: FormStep[] = [submitterStep]
if (s.sections.length > 0 && s.section_level_submit === false) {
const sectionsSorted = [...s.sections].sort((a, b) => a.sort_order - b.sort_order)
for (const section of sectionsSorted) {
const fields = sorted.filter(f => f.form_schema_section_id === section.id)
steps.push({
key: `section-${section.id}`,
kind: 'section',
title: section.name,
subtitle: section.description ?? undefined,
fields,
})
}
const loose = sorted.filter(f => f.form_schema_section_id === null)
if (loose.length > 0) {
steps.push({
key: 'section-loose',
kind: 'section',
title: 'Overig',
fields: loose,
})
}
}
else if (sorted.some(f => f.field_type === FormFieldType.HEADING)) {
steps.push(...partitionByHeading(sorted))
}
else {
steps.push({
key: 'all-fields',
kind: 'flat',
title: 'Vragen',
fields: sorted,
})
}
steps.push(reviewStep)
return steps
})
}
/**
* Returns true when all visible required fields in `step` have a
* non-empty value. Hidden fields (failing conditional logic) are
* skipped. HEADING/PARAGRAPH fields carry no value and are skipped.
*/
export function isStepValid(
step: FormStep,
values: FormValues,
submitterValid: boolean,
): boolean {
if (step.kind === 'submitter') return submitterValid
if (step.kind === 'review') return true
for (const field of step.fields) {
if (field.field_type === FormFieldType.HEADING || field.field_type === FormFieldType.PARAGRAPH) continue
if (!field.is_required) continue
if (!evaluateConditionalLogic(field.conditional_logic, values)) continue
if (isFieldValueEmpty(values[field.slug])) return false
}
return true
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
import { emailValidator, regexValidator, requiredValidator, urlValidator } from '@core/utils/validators'
import { FormFieldType } from '@/types/formBuilder'
import type { PublicFormField } from '@/types/formBuilder'
export type Validator = (value: unknown) => true | string
/**
* Build a list of client-side validators for a public form field.
* Runs in VTextField `:rules` and the stepper "current step valid"
* gate. Mirrors (a subset of) the backend relaxed rule set so the
* submitter gets feedback before the submit round-trip.
*/
export function getValidatorsForField(field: PublicFormField): Validator[] {
const rules: Validator[] = []
const v = field.validation_rules ?? {}
if (field.is_required) {
rules.push(value => {
const ok = requiredValidator(value)
return ok === true ? true : 'Dit veld is verplicht.'
})
}
switch (field.field_type) {
case FormFieldType.EMAIL:
rules.push(value => {
const ok = emailValidator(value)
return ok === true ? true : 'Vul een geldig e-mailadres in.'
})
break
case FormFieldType.URL:
rules.push(value => {
const ok = urlValidator(value)
return ok === true ? true : 'Vul een geldige URL in (beginnend met http:// of https://).'
})
break
case FormFieldType.PHONE:
rules.push(value => {
if (value === null || value === undefined || value === '') return true
const s = String(value).replace(/\s+/g, '')
return /^\+?[\d()-]{6,}$/.test(s) || 'Vul een geldig telefoonnummer in.'
})
break
case FormFieldType.NUMBER:
rules.push(value => {
if (value === null || value === undefined || value === '') return true
const n = Number(value)
return Number.isFinite(n) || 'Vul een geldig getal in.'
})
if (typeof v.min === 'number') {
const min = v.min
rules.push(value => {
if (value === null || value === undefined || value === '') return true
const n = Number(value)
return Number.isFinite(n) && n >= min ? true : `Minimaal ${min}.`
})
}
if (typeof v.max === 'number') {
const max = v.max
rules.push(value => {
if (value === null || value === undefined || value === '') return true
const n = Number(value)
return Number.isFinite(n) && n <= max ? true : `Maximaal ${max}.`
})
}
break
case FormFieldType.TEXT:
case FormFieldType.TEXTAREA:
if (typeof v.min === 'number') {
const min = v.min
rules.push(value => {
if (value === null || value === undefined || value === '') return true
return String(value).length >= min ? true : `Minimaal ${min} tekens.`
})
}
if (typeof v.max === 'number') {
const max = v.max
rules.push(value => {
if (value === null || value === undefined || value === '') return true
return String(value).length <= max ? true : `Maximaal ${max} tekens.`
})
}
if (typeof v.pattern === 'string' && v.pattern.length > 0) {
const pattern = v.pattern
rules.push(value => {
if (value === null || value === undefined || value === '') return true
const ok = regexValidator(value, pattern)
return ok === true ? true : 'Ongeldige invoer.'
})
}
break
case FormFieldType.MULTISELECT:
case FormFieldType.CHECKBOX_LIST:
if (typeof v.min_selections === 'number') {
const min = v.min_selections
rules.push(value => {
const arr = Array.isArray(value) ? value : []
return arr.length >= min ? true : `Kies er minimaal ${min}.`
})
}
if (typeof v.max_selections === 'number') {
const max = v.max_selections
rules.push(value => {
const arr = Array.isArray(value) ? value : []
return arr.length <= max ? true : `Kies er maximaal ${max}.`
})
}
break
default:
break
}
return rules
}
export function runValidators(rules: Validator[], value: unknown): string | true {
for (const rule of rules) {
const r = rule(value)
if (r !== true) return r
}
return true
}
export function isFieldValueEmpty(value: unknown): boolean {
if (value === null || value === undefined) return true
if (typeof value === 'string') return value.trim() === ''
if (Array.isArray(value)) return value.length === 0
return false
}