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:
535
apps/portal/src/pages/register/[public_token].vue
Normal file
535
apps/portal/src/pages/register/[public_token].vue
Normal 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>
|
||||
Reference in New Issue
Block a user