feat(portal): persist submitter details through draft lifecycle

Adds submitterName/submitterEmail state and setters to useFormDraft and
wires them through start/saveDraft/submit. Previously the Contactgegevens
name/email were held in a local page ref and never made it into any
request body, so submissions landed in the DB with NULL submitter fields
and a mid-form reload wiped whatever the user had typed.

- useFormDraft: internal submitterName/submitterEmail refs with setters
  that mark the draft dirty (same debounced-PUT path as field values),
  sessionStorage resume via draft_submitter:{token}, and a
  MISSING_SUBMITTER guard in submitForm so empty fields surface as
  submitError without hitting the endpoint.
- register/[public_token].vue: deletes the local submitter refs and
  reads/writes through the composable; onSubmit pre-validates and
  bounces the user back to the Contactgegevens step with a snackbar
  when fields are missing.
- SaveDraftBody / SubmitBody: optional public_submitter_name and
  public_submitter_email per the documented backend contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 14:08:13 +02:00
parent 102b6006fa
commit 3ecd4daee1
4 changed files with 1255 additions and 0 deletions

View File

@@ -0,0 +1,535 @@
<script setup lang="ts">
import { emailValidator } from '@core/utils/validators'
import FieldRenderer from '@/components/public-form/FieldRenderer.vue'
import FormConfirmation from '@/components/public-form/FormConfirmation.vue'
import FormErrorState from '@/components/public-form/FormErrorState.vue'
import FormStepper from '@/components/public-form/FormStepper.vue'
import SubmitterDetails from '@/components/public-form/SubmitterDetails.vue'
import { extractErrorBody, useFetchPublicFormSchema } from '@/composables/api/usePublicForm'
import { useFormDraft } from '@/composables/useFormDraft'
import { isStepValid, useFormSteps } from '@/composables/useFormSteps'
import { FormFieldType } from '@/types/formBuilder'
import type { FormErrorCode, PublicFormField } from '@/types/formBuilder'
definePage({
name: 'public-form-register',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
const route = useRoute('public-form-register')
const token = computed(() => {
const raw = route.params.public_token
if (Array.isArray(raw)) return raw[0] ?? ''
return raw ?? ''
})
const tokenRef = computed<string | null>(() => token.value || null)
const schemaQuery = useFetchPublicFormSchema(tokenRef)
const draft = useFormDraft(tokenRef, {
locale: 'nl',
})
// Start the draft as soon as the schema resolves successfully.
watch(() => schemaQuery.data.value?.id, async id => {
if (id && !draft.submission.value) {
await draft.start()
}
}, { immediate: true })
const steps = useFormSteps(schemaQuery.data)
const currentStep = ref(0)
const justSubmitted = ref(false)
const showSaveToast = ref(false)
const rateLimitedToast = ref(false)
const submitterToast = ref(false)
const serverFieldErrors = ref<Record<string, string[]>>({})
const submitterErrors = ref<{ name?: string; email?: string }>({})
const submitterValid = computed(() => {
const hasName = draft.submitterName.value.trim().length > 0
const hasEmail = draft.submitterEmail.value.trim().length > 0
const emailOk = emailValidator(draft.submitterEmail.value) === true
return hasName && hasEmail && emailOk
})
const activeStep = computed(() => steps.value[currentStep.value])
const isActiveStepValid = computed(() =>
activeStep.value ? isStepValid(activeStep.value, draft.values.value, submitterValid.value) : true,
)
const saveStatusText = computed(() => {
if (draft.isSaving.value) return 'Opslaan...'
if (draft.lastSavedAt.value) {
const hh = String(draft.lastSavedAt.value.getHours()).padStart(2, '0')
const mm = String(draft.lastSavedAt.value.getMinutes()).padStart(2, '0')
return `Concept opgeslagen om ${hh}:${mm}`
}
return ''
})
const terminalErrorCode = computed<FormErrorCode | null>(() => {
const err = schemaQuery.error.value
if (!err) return null
const body = extractErrorBody(err)
const axiosErr = err as { response?: { status?: number } }
const status = axiosErr.response?.status
const code = body?.code as FormErrorCode | undefined
if (code === 'TOKEN_EXPIRED' || code === 'TOKEN_REVOKED'
|| code === 'SCHEMA_NOT_FOUND' || code === 'SCHEMA_UNPUBLISHED') {
return code
}
if (status === 404) return 'SCHEMA_NOT_FOUND'
if (status === 410) return 'TOKEN_EXPIRED'
return null
})
const submitErrorCode = computed<FormErrorCode | null>(() => {
const err = draft.submitError.value
if (!err) return null
const body = extractErrorBody(err)
const code = body?.code as FormErrorCode | undefined
if (code === 'SUBMISSION_ALREADY_SUBMITTED'
|| code === 'RATE_LIMITED'
|| code === 'SCHEMA_UNPUBLISHED') {
return code
}
return null
})
function onFieldValue(slug: string, value: unknown): void {
draft.setValue(slug, value)
if (serverFieldErrors.value[slug]) {
const next = { ...serverFieldErrors.value }
delete next[slug]
serverFieldErrors.value = next
}
}
function onFieldBlur(slug: string, value: unknown): void {
draft.saveField(slug, value)
}
async function nextStep(): Promise<void> {
if (!isActiveStepValid.value) return
if (currentStep.value < steps.value.length - 1) {
currentStep.value++
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
function prevStep(): void {
if (currentStep.value > 0) {
currentStep.value--
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
async function onSaveDraft(): Promise<void> {
await draft.saveDraftNow()
if (!draft.saveError.value) {
showSaveToast.value = true
}
else {
const body = extractErrorBody(draft.saveError.value)
if (body?.code === 'RATE_LIMITED') rateLimitedToast.value = true
}
}
function onSubmitterNameUpdate(v: string): void {
draft.setSubmitterName(v)
if (submitterErrors.value.name) submitterErrors.value = { ...submitterErrors.value, name: undefined }
}
function onSubmitterEmailUpdate(v: string): void {
draft.setSubmitterEmail(v)
if (submitterErrors.value.email) submitterErrors.value = { ...submitterErrors.value, email: undefined }
}
function validateSubmitter(): boolean {
const errs: { name?: string; email?: string } = {}
const name = draft.submitterName.value.trim()
const email = draft.submitterEmail.value.trim()
if (!name) errs.name = 'Vul je naam in.'
if (!email) errs.email = 'Vul je e-mailadres in.'
else if (emailValidator(email) !== true) errs.email = 'Vul een geldig e-mailadres in.'
submitterErrors.value = errs
return !errs.name && !errs.email
}
async function onSubmit(): Promise<void> {
serverFieldErrors.value = {}
if (!validateSubmitter()) {
const idx = steps.value.findIndex(s => s.kind === 'submitter')
if (idx >= 0) currentStep.value = idx
submitterToast.value = true
return
}
const result = await draft.submitForm()
if (result) {
justSubmitted.value = true
window.scrollTo({ top: 0 })
return
}
const err = draft.submitError.value
const body = extractErrorBody(err)
if (body?.errors) serverFieldErrors.value = body.errors
if (body?.code === 'RATE_LIMITED') rateLimitedToast.value = true
}
function serverErrorFor(slug: string): string[] {
return serverFieldErrors.value[slug] ?? serverFieldErrors.value[`values.${slug}`] ?? []
}
function retryFetch(): void {
void schemaQuery.refetch()
}
function answerableForReview(field: PublicFormField): boolean {
return field.field_type !== FormFieldType.HEADING
&& field.field_type !== FormFieldType.PARAGRAPH
}
function formatReviewValue(field: PublicFormField): string {
const v = draft.values.value[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)
}
</script>
<template>
<div class="public-form-page">
<!-- Terminal fetch error -->
<FormErrorState
v-if="terminalErrorCode"
:error-code="terminalErrorCode"
:show-retry="false"
/>
<!-- Generic fetch error (retryable) -->
<FormErrorState
v-else-if="schemaQuery.isError.value && !schemaQuery.data.value"
@retry="retryFetch"
/>
<!-- Loading -->
<div
v-else-if="schemaQuery.isLoading.value || !schemaQuery.data.value"
class="d-flex justify-center pa-4"
>
<VCard
flat
:max-width="720"
class="w-100 pa-4"
>
<VSkeletonLoader type="article" />
</VCard>
</div>
<!-- Already-submitted terminal -->
<FormErrorState
v-else-if="submitErrorCode === 'SUBMISSION_ALREADY_SUBMITTED'"
error-code="SUBMISSION_ALREADY_SUBMITTED"
:show-retry="false"
/>
<!-- Post-submit confirmation -->
<FormConfirmation
v-else-if="justSubmitted"
:steps="steps"
:values="draft.values.value"
:submitter-name="draft.submitterName.value"
:submitter-email="draft.submitterEmail.value"
/>
<!-- Data state -->
<div v-else>
<!-- Save-status top bar -->
<div
v-if="saveStatusText || draft.saveError.value"
class="d-flex justify-end px-4 pt-3"
>
<VChip
v-if="saveStatusText"
size="small"
variant="tonal"
:color="draft.isSaving.value ? 'primary' : 'success'"
>
<template
v-if="draft.isSaving.value"
#prepend
>
<VProgressCircular
indeterminate
size="14"
width="2"
class="me-2"
/>
</template>
{{ saveStatusText }}
</VChip>
<VChip
v-else-if="draft.saveError.value"
size="small"
variant="tonal"
color="warning"
>
<VIcon
start
icon="tabler-cloud-off"
size="16"
/>
Opslaan mislukt
</VChip>
</div>
<VContainer class="public-form-container">
<VCard
v-if="schemaQuery.data.value"
flat
class="pa-4 mb-4"
>
<h1 class="text-h5 mb-1">
{{ schemaQuery.data.value.name }}
</h1>
<p
v-if="schemaQuery.data.value.description"
class="text-body-2 text-medium-emphasis mb-0"
>
{{ schemaQuery.data.value.description }}
</p>
</VCard>
<!-- Stepper navigation -->
<VCard
flat
class="pa-4 mb-4"
>
<FormStepper
v-model:current-step="currentStep"
:steps="steps"
:is-active-step-valid="isActiveStepValid"
/>
</VCard>
<!-- Current step -->
<VCard
flat
class="pa-4 pa-sm-6"
>
<div
v-if="activeStep"
class="mb-6"
>
<p class="text-caption text-medium-emphasis mb-1">
Stap {{ currentStep + 1 }} van {{ steps.length }}
</p>
<h2 class="text-h5 mb-1">
{{ activeStep.title }}
</h2>
<p
v-if="activeStep.subtitle"
class="text-body-2 text-medium-emphasis mb-0"
>
{{ activeStep.subtitle }}
</p>
</div>
<!-- Submitter step -->
<SubmitterDetails
v-if="activeStep && activeStep.kind === 'submitter'"
:name="draft.submitterName.value"
:email="draft.submitterEmail.value"
:errors="submitterErrors"
@update:name="onSubmitterNameUpdate"
@update:email="onSubmitterEmailUpdate"
@blur="draft.saveDraftNow"
/>
<!-- Review step -->
<div v-else-if="activeStep && activeStep.kind === 'review'">
<VRow>
<VCol
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
Naam
</p>
<p class="text-body-2 mb-0">
{{ draft.submitterName.value || '—' }}
</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">
{{ draft.submitterEmail.value || '—' }}
</p>
</VCol>
</VRow>
<template
v-for="step in steps"
:key="step.key"
>
<template v-if="step.kind !== 'submitter' && step.kind !== 'review'">
<VDivider class="my-5" />
<h3 class="text-subtitle-1 font-weight-medium mb-3">
{{ step.title }}
</h3>
<VRow>
<VCol
v-for="field in step.fields.filter(answerableForReview)"
: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">
{{ formatReviewValue(field) }}
</p>
</VCol>
</VRow>
</template>
</template>
</div>
<!-- Content steps -->
<div v-else-if="activeStep">
<VRow>
<FieldRenderer
v-for="field in activeStep.fields"
:key="field.id"
:field="field"
:model-value="draft.values.value[field.slug]"
:all-values="draft.values.value"
:error-messages="serverErrorFor(field.slug)"
@update:model-value="v => onFieldValue(field.slug, v)"
@blur="onFieldBlur(field.slug, draft.values.value[field.slug])"
/>
</VRow>
</div>
<VDivider class="my-6" />
<div class="d-flex flex-wrap justify-space-between align-center ga-3">
<VBtn
v-if="currentStep > 0"
variant="outlined"
color="secondary"
@click="prevStep"
>
<VIcon
icon="tabler-arrow-left"
start
/>
Vorige
</VBtn>
<div v-else />
<div class="d-flex flex-wrap ga-3">
<VBtn
v-if="activeStep && activeStep.kind !== 'review'"
variant="text"
color="primary"
:loading="draft.isSaving.value"
:disabled="!draft.submission.value"
@click="onSaveDraft"
>
Sla op als concept
</VBtn>
<VBtn
v-if="currentStep < steps.length - 1"
color="primary"
:disabled="!isActiveStepValid"
@click="nextStep"
>
Volgende
<VIcon
icon="tabler-arrow-right"
end
/>
</VBtn>
<VBtn
v-else
color="success"
:loading="draft.isSubmitting.value"
@click="onSubmit"
>
<VIcon
icon="tabler-send"
start
/>
Verstuur
</VBtn>
</div>
</div>
</VCard>
</VContainer>
</div>
<VSnackbar
v-model="showSaveToast"
:timeout="2500"
color="success"
location="top"
>
Concept opgeslagen.
</VSnackbar>
<VSnackbar
v-model="rateLimitedToast"
:timeout="4000"
color="warning"
location="top"
>
Even geduld, we proberen het zo opnieuw.
</VSnackbar>
<VSnackbar
v-model="submitterToast"
:timeout="3000"
color="error"
location="top"
>
Vul eerst je contactgegevens in
</VSnackbar>
</div>
</template>
<style scoped>
.public-form-container {
max-inline-size: 960px;
}
.public-form-page {
min-block-size: 100dvh;
}
</style>