Informational hint on the confirmation page when the same email has already submitted the form. Not a block — the submission proceeds normally. Privacy-safe: only shown to the submitter themselves. Scope: same form_schema_id only. Cross-form/cross-event detection would leak info about other forms. - New FormSubmissionDuplicateDetector service queries by form_submissions.public_submitter_email (trim + case-insensitive) scoped to the schema, status=submitted, excluding the current submission. Errors are swallowed + logged so a detector failure never blocks the submit response. - PublicFormSubmissionController enriches the submit response by setting a transient duplicate_submission_data attribute on the submission before resource serialisation. - PublicFormSubmissionResource serialises a duplicate_submission block with count, first_submitted_at, plus backend-authored Dutch title + body (plural-agreement + IntlDateFormatter for "23 april 2026"-style long-form dates). Null when no priors, no email, or detector error. - DuplicateSubmissionHint.vue (warning-typed tonal VAlert) above IdentityMatchBanner on FormConfirmation. Prefers backend copy with Intl-based Dutch date fallback for safety. - 16 new backend assertions across the detector and the full submit-response flow; 5 new Vitest assertions for the hint. Note on scope: spec suggested extracting email from values via schema binding; the codebase's public flow captures submitter email in a guaranteed column (public_submitter_email) populated by the stepper's Contactgegevens step. Using that directly is both simpler and more correct for the duplicate-by-submitter semantic. When FORM-05's binding-based extractor lands, this detector can migrate without changing its public API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
149 lines
4.3 KiB
Vue
149 lines
4.3 KiB
Vue
<script setup lang="ts">
|
|
import DuplicateSubmissionHint from './DuplicateSubmissionHint.vue'
|
|
import IdentityMatchBanner from './IdentityMatchBanner.vue'
|
|
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
|
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
|
import { formatFieldValue } from '@/composables/formatFieldValue'
|
|
import type { FormStep } from '@/composables/useFormSteps'
|
|
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
|
import { FormFieldType } from '@/types/formBuilder'
|
|
import type {
|
|
FormValues,
|
|
PublicFormField,
|
|
PublicFormSubmissionDuplicate,
|
|
PublicFormSubmissionIdentityMatch,
|
|
} from '@/types/formBuilder'
|
|
|
|
const props = defineProps<{
|
|
steps: FormStep[]
|
|
values: FormValues
|
|
submitterName?: string
|
|
submitterEmail?: string
|
|
identityMatch?: PublicFormSubmissionIdentityMatch | null
|
|
duplicateSubmission?: PublicFormSubmissionDuplicate | null
|
|
}>()
|
|
|
|
// TanStack Query calls — these hit the same cache the field components
|
|
// populated during the form render (5-minute staleTime), so there's no
|
|
// extra network round-trip on the confirmation page.
|
|
const token = usePublicFormToken()
|
|
const timeSlotsQuery = usePublicFormTimeSlots(token)
|
|
const sectionsQuery = usePublicFormSections(token)
|
|
|
|
function displayValue(field: PublicFormField): string {
|
|
return formatFieldValue(
|
|
field,
|
|
props.values[field.slug],
|
|
timeSlotsQuery.data.value,
|
|
sectionsQuery.data.value,
|
|
)
|
|
}
|
|
|
|
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
|
|
v-if="duplicateSubmission || identityMatch"
|
|
class="pa-6 pb-0"
|
|
>
|
|
<!-- Duplicate hint first: it's about the act of submitting.
|
|
Identity match second: it's about who you are. -->
|
|
<DuplicateSubmissionHint :data="duplicateSubmission ?? null" />
|
|
<IdentityMatchBanner
|
|
v-if="identityMatch"
|
|
:status="identityMatch.status"
|
|
:message="identityMatch.message"
|
|
/>
|
|
</VCardText>
|
|
|
|
<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>
|