feat(portal): implement TAG_PICKER, AVAILABILITY_PICKER, SECTION_PRIORITY field types

- FieldTagPicker: VAutocomplete multiple with grouped category slots,
  empty/null category normalised to "Overig", empty-state info alert
  when the server delivers no tags.
- FieldAvailabilityPicker: date-grouped checkbox list, festival-aware
  via usePublicFormTimeSlots. Event-name subheaders only surface when
  the time-slots span multiple events. Time format strips seconds.
- FieldSectionPriority: tap-to-rank + drag-to-reorder via vuedraggable
  for desktop; mobile tap-only. Renumbers priorities on every mutation.
  Self-heals malformed modelValue. UI soft cap via
  validation_rules.max_priorities clamped to the backend hard cap of 5.
- FieldRenderer: three new types removed from isStubbed.
- publicFormInjection: page-level provide/inject for the public token.
- IdentityMatchBanner: prefers backend-provided Dutch copy with
  frontend defaults as defensive fallback.
- FormConfirmation wires the banner inline.
- usePublicFormTimeSlots and usePublicFormSections TanStack composables.
- 40 new Vitest assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 20:00:40 +02:00
parent 1a87871e94
commit 9256c05db0
22 changed files with 1768 additions and 9 deletions

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
import { usePublicFormToken } from '@/composables/publicFormInjection'
import type { PublicFormField, PublicFormTimeSlot } 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
}>()
const token = usePublicFormToken()
const { data: slots, isLoading, isError, refetch } = usePublicFormTimeSlots(token)
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 []
})
const isEmpty = computed(() => !slots.value || slots.value.length === 0)
const hasMultipleEvents = computed(() => {
if (!slots.value) return false
return new Set(slots.value.map(s => s.event_id)).size > 1
})
interface DateGroup {
date: string
label: string
events: Array<{ eventId: string; eventName: string; slots: PublicFormTimeSlot[] }>
}
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
weekday: 'long',
day: 'numeric',
month: 'long',
})
function formatDateLabel(iso: string): string {
try {
const d = new Date(`${iso}T00:00:00`)
const parts = dateFormatter.format(d)
return parts.charAt(0).toUpperCase() + parts.slice(1)
} catch {
return iso
}
}
function stripSeconds(t: string): string {
// "08:00:00" → "08:00"
const parts = t.split(':')
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : t
}
const groups = computed<DateGroup[]>(() => {
const data = slots.value ?? []
// Group by date → then by event_id within the date. Preserve the order
// the server already sorted in (by date asc, start_time asc).
const byDate = new Map<string, Map<string, { eventId: string; eventName: string; slots: PublicFormTimeSlot[] }>>()
for (const slot of data) {
let events = byDate.get(slot.date)
if (!events) {
events = new Map()
byDate.set(slot.date, events)
}
let bucket = events.get(slot.event_id)
if (!bucket) {
bucket = { eventId: slot.event_id, eventName: slot.event_name, slots: [] }
events.set(slot.event_id, bucket)
}
bucket.slots.push(slot)
}
return Array.from(byDate.entries()).map(([date, eventsMap]) => ({
date,
label: formatDateLabel(date),
events: Array.from(eventsMap.values()),
}))
})
function isChecked(id: string): boolean {
return selected.value.includes(id)
}
function toggle(id: string, checked: boolean | null): void {
const next = [...selected.value]
const idx = next.indexOf(id)
if (checked) {
if (idx === -1) next.push(id)
} 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>
<VSkeletonLoader
v-if="isLoading"
type="article"
/>
<VAlert
v-else-if="isError"
type="error"
variant="tonal"
density="comfortable"
>
<div class="d-flex flex-wrap align-center justify-space-between ga-3">
<span>Kon beschikbaarheidsopties niet laden.</span>
<VBtn
size="small"
variant="outlined"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</div>
</VAlert>
<VAlert
v-else-if="isEmpty"
type="info"
variant="tonal"
density="comfortable"
>
Er zijn nog geen tijdsloten beschikbaar.
</VAlert>
<div
v-else
class="availability-groups"
>
<div
v-for="group in groups"
:key="group.date"
class="availability-date-group mb-4"
>
<div class="text-subtitle-2 mb-2">
{{ group.label }}
</div>
<template
v-for="ev in group.events"
:key="`${group.date}-${ev.eventId}`"
>
<div
v-if="hasMultipleEvents"
class="text-caption text-medium-emphasis mb-1"
>
{{ ev.eventName }}
</div>
<VCheckbox
v-for="slot in ev.slots"
:key="slot.id"
:model-value="isChecked(slot.id)"
density="comfortable"
hide-details
class="availability-slot-checkbox"
@update:model-value="(v: boolean | null) => toggle(slot.id, v)"
>
<template #label>
<div>
<span class="text-body-1">{{ slot.name }}</span>
<span class="text-caption text-medium-emphasis ml-2">
({{ stripSeconds(slot.start_time) }}{{ stripSeconds(slot.end_time) }})
</span>
</div>
</template>
</VCheckbox>
</template>
</div>
</div>
<div
v-if="displayedErrors.length"
class="text-caption text-error mt-1"
>
{{ displayedErrors[0] }}
</div>
</div>
</template>
<style scoped>
/* Vuetify's default VCheckbox spacing is tight; adding a little block
margin gives the date-grouped list an easier scan rhythm. */
.availability-slot-checkbox {
margin-block: 2px;
}
</style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import FieldAvailabilityPicker from './FieldAvailabilityPicker.vue'
import FieldBoolean from './FieldBoolean.vue'
import FieldCheckboxList from './FieldCheckboxList.vue'
import FieldDate from './FieldDate.vue'
@@ -9,7 +10,9 @@ import FieldNumber from './FieldNumber.vue'
import FieldParagraph from './FieldParagraph.vue'
import FieldPhone from './FieldPhone.vue'
import FieldRadio from './FieldRadio.vue'
import FieldSectionPriority from './FieldSectionPriority.vue'
import FieldSelect from './FieldSelect.vue'
import FieldTagPicker from './FieldTagPicker.vue'
import FieldText from './FieldText.vue'
import FieldTextarea from './FieldTextarea.vue'
import FieldUrl from './FieldUrl.vue'
@@ -45,10 +48,7 @@ function colsFor(width: FormFieldDisplayWidth): number {
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.FILE_UPLOAD
|| props.field.field_type === FormFieldType.IMAGE_UPLOAD
|| props.field.field_type === FormFieldType.SIGNATURE
|| props.field.field_type === FormFieldType.TABLE_ROWS
@@ -185,6 +185,33 @@ function onBlur(): void {
@blur="onBlur"
/>
<FieldTagPicker
v-else-if="field.field_type === FormFieldType.TAG_PICKER"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldAvailabilityPicker
v-else-if="field.field_type === FormFieldType.AVAILABILITY_PICKER"
:field="field"
:model-value="modelValue"
:error-messages="errorMessages"
@update:model-value="onUpdate"
@blur="onBlur"
/>
<FieldSectionPriority
v-else-if="field.field_type === FormFieldType.SECTION_PRIORITY"
: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"

View File

@@ -0,0 +1,343 @@
<script setup lang="ts">
import draggable from 'vuedraggable'
import { useDisplay } from 'vuetify'
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
import { usePublicFormToken } from '@/composables/publicFormInjection'
import type { PublicFormField, PublicFormSectionOption, SectionPriorityValue } 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: SectionPriorityValue[]): void
(e: 'blur'): void
}>()
const token = usePublicFormToken()
const { data: sections, isLoading, isError, refetch } = usePublicFormSections(token)
const { mobile } = useDisplay()
const HARD_CAP = 5
const warnedOnMalformedValue = { value: false }
function selfHealIncoming(value: unknown): SectionPriorityValue[] {
if (!Array.isArray(value)) return []
const cleaned: SectionPriorityValue[] = []
for (const entry of value) {
if (entry === null || typeof entry !== 'object') continue
const obj = entry as Record<string, unknown>
const id = obj.section_id
const prio = obj.priority
if (typeof id !== 'string' || typeof prio !== 'number') continue
cleaned.push({ section_id: id, priority: prio })
}
// If the inbound array had data but none of it matched the shape,
// warn once so future misuses are spotted in dev.
if (import.meta.env.DEV && value.length > 0 && cleaned.length === 0 && !warnedOnMalformedValue.value) {
console.warn('[FieldSectionPriority] modelValue not in {section_id, priority}[] shape — self-healing to [].')
warnedOnMalformedValue.value = true
}
return cleaned
}
const ranked = ref<SectionPriorityValue[]>(selfHealIncoming(props.modelValue))
watch(() => props.modelValue, v => {
ranked.value = selfHealIncoming(v)
}, { deep: true })
const maxPriorities = computed(() => {
const raw = props.field.validation_rules?.max_priorities
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
return Math.min(raw, HARD_CAP)
}
return HARD_CAP
})
const sectionsById = computed<Record<string, PublicFormSectionOption>>(() => {
const list = sections.value ?? []
const map: Record<string, PublicFormSectionOption> = {}
for (const s of list) map[s.id] = s
return map
})
const unrankedPool = computed<PublicFormSectionOption[]>(() => {
const list = sections.value ?? []
const rankedIds = new Set(ranked.value.map(r => r.section_id))
return list.filter(s => !rankedIds.has(s.id))
})
const isEmpty = computed(() => !sections.value || sections.value.length === 0)
const rankedFull = computed(() => ranked.value.length >= maxPriorities.value)
const rules = computed(() => getValidatorsForField(props.field))
const clientError = computed(() => {
const res = runValidators(rules.value, ranked.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 reassignPriorities(list: SectionPriorityValue[]): SectionPriorityValue[] {
return list.map((item, index) => ({ section_id: item.section_id, priority: index + 1 }))
}
function emitNow(): void {
emit('update:modelValue', ranked.value)
emit('blur')
}
function rankSection(section: PublicFormSectionOption): void {
if (rankedFull.value) return
ranked.value = reassignPriorities([
...ranked.value,
{ section_id: section.id, priority: ranked.value.length + 1 },
])
emitNow()
}
function unrankAt(index: number): void {
const next = [...ranked.value]
next.splice(index, 1)
ranked.value = reassignPriorities(next)
emitNow()
}
function onDragEnd(): void {
// vuedraggable already mutated ranked.value via v-model — we just
// renumber priorities and emit.
ranked.value = reassignPriorities(ranked.value)
emitNow()
}
function sectionNameFor(id: string): string {
return sectionsById.value[id]?.name ?? ''
}
</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>
<VSkeletonLoader
v-if="isLoading"
type="article"
/>
<VAlert
v-else-if="isError"
type="error"
variant="tonal"
density="comfortable"
>
<div class="d-flex flex-wrap align-center justify-space-between ga-3">
<span>Kon secties niet laden.</span>
<VBtn
size="small"
variant="outlined"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</div>
</VAlert>
<VAlert
v-else-if="isEmpty"
type="info"
variant="tonal"
density="comfortable"
>
Er zijn nog geen secties gepubliceerd voor registratie.
</VAlert>
<div v-else>
<!-- Ranked list -->
<div class="mb-2 text-caption text-medium-emphasis">
Jouw voorkeuren ({{ ranked.length }} / {{ maxPriorities }})
</div>
<draggable
v-model="ranked"
item-key="section_id"
handle=".section-priority-handle"
:animation="180"
:delay="100"
:delay-on-touch-only="true"
class="section-priority-ranked mb-4"
@end="onDragEnd"
>
<template #item="{ element, index }">
<VCard
variant="outlined"
class="section-priority-ranked-item d-flex align-center pa-3 mb-2"
:aria-label="`Voorkeur ${index + 1}: ${sectionNameFor(element.section_id)}`"
>
<div class="section-priority-rank me-3 text-primary font-weight-bold text-h6">
{{ index + 1 }}
</div>
<VIcon
v-if="sectionsById[element.section_id]?.icon"
:icon="sectionsById[element.section_id]!.icon!"
size="18"
class="me-2 text-medium-emphasis"
/>
<div class="flex-grow-1">
<div class="text-body-1">
{{ sectionNameFor(element.section_id) }}
</div>
<div
v-if="sectionsById[element.section_id]?.registration_description"
class="text-caption text-medium-emphasis"
>
{{ sectionsById[element.section_id]!.registration_description }}
</div>
</div>
<VIcon
v-if="!mobile"
class="section-priority-handle me-2 text-disabled"
style="cursor: grab;"
icon="tabler-grip-vertical"
size="18"
/>
<VBtn
icon="tabler-x"
size="small"
variant="text"
:aria-label="`Verwijder ${sectionNameFor(element.section_id)} uit je voorkeuren`"
@click="unrankAt(index)"
/>
</VCard>
</template>
</draggable>
<div
v-if="ranked.length === 0"
class="text-caption text-medium-emphasis mb-4"
>
Tik of sleep een sectie hieronder om je eerste voorkeur te kiezen.
</div>
<!-- Unranked pool -->
<div class="mb-2 text-caption text-medium-emphasis">
Nog te kiezen
</div>
<VRow dense>
<VCol
v-for="section in unrankedPool"
:key="section.id"
cols="12"
sm="6"
>
<VCard
variant="outlined"
class="section-priority-unranked-card pa-3"
:class="{ 'section-priority-unranked-disabled': rankedFull }"
role="button"
tabindex="0"
:aria-label="`Voeg ${section.name} toe aan je voorkeuren`"
:aria-disabled="rankedFull"
@click="rankSection(section)"
@keydown.enter.prevent="rankSection(section)"
@keydown.space.prevent="rankSection(section)"
>
<div class="d-flex align-center">
<VIcon
v-if="section.icon"
:icon="section.icon"
size="18"
class="me-2 text-medium-emphasis"
/>
<div class="flex-grow-1">
<div class="text-body-1">
{{ section.name }}
</div>
<div
v-if="section.registration_description"
class="text-caption text-medium-emphasis"
>
{{ section.registration_description }}
</div>
</div>
<VIcon
v-if="!rankedFull"
icon="tabler-plus"
size="18"
class="text-primary"
/>
</div>
<div
v-if="rankedFull"
class="text-caption text-medium-emphasis mt-1"
>
Maximaal {{ maxPriorities }} voorkeuren
</div>
</VCard>
</VCol>
</VRow>
<div
v-if="displayedErrors.length"
class="text-caption text-error mt-2"
>
{{ displayedErrors[0] }}
</div>
</div>
</div>
</template>
<style scoped>
/* Vuetify's VCard disabled state isn't contextual enough for an
"over-cap" affordance — we need it to look dimmed without blocking
assistive tech, which Vuetify's :disabled would do. */
.section-priority-unranked-card {
cursor: pointer;
transition: background-color 120ms;
}
.section-priority-unranked-card:hover,
.section-priority-unranked-card:focus-visible {
background-color: rgb(var(--v-theme-surface-variant));
}
.section-priority-unranked-disabled {
cursor: not-allowed;
opacity: 0.6;
}
.section-priority-unranked-disabled:hover,
.section-priority-unranked-disabled:focus-visible {
background-color: inherit;
}
.section-priority-rank {
min-inline-size: 1.5rem;
text-align: center;
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { AvailableTag, 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 NormalizedTag {
title: string
value: string
category: string
}
const OVERIG = 'Overig'
function normalize(tag: AvailableTag): NormalizedTag {
const category = (tag.category && tag.category.trim() !== '') ? tag.category : OVERIG
return { title: tag.name, value: tag.id, category }
}
const items = computed<NormalizedTag[]>(() => {
const tags = props.field.available_tags ?? []
// Server already orders within each category; we only need a stable
// per-category grouping so the #item slot can emit a subheader when
// the category flips.
return [...tags].map(normalize).sort((a, b) => {
if (a.category === b.category) return 0
return a.category < b.category ? -1 : 1
})
})
const isEmpty = computed(() => items.value.length === 0)
const model = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue as string[] : []),
set: (v: string[]) => emit('update:modelValue', v),
})
const rules = computed(() => getValidatorsForField(props.field))
// Tracks the previous category rendered during the #item slot iteration
// so we can emit a VListSubheader right before the first item in each
// new category. VAutocomplete re-iterates on every render.
let lastCategory: string | null = null
function shouldRenderSubheader(category: string): boolean {
const flip = lastCategory !== category
lastCategory = category
return flip
}
function resetSubheaderTracker(): void {
lastCategory = null
}
</script>
<template>
<div>
<VAlert
v-if="isEmpty"
type="info"
variant="tonal"
density="comfortable"
>
<div class="text-subtitle-2 mb-1">
{{ field.label }}<span
v-if="field.is_required"
class="text-error"
> *</span>
</div>
<div class="text-body-2">
Er zijn nog geen tags beschikbaar voor dit formulier.
</div>
</VAlert>
<AppAutocomplete
v-else
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:menu="(open: boolean) => { if (open) resetSubheaderTracker() }"
@update:model-value="emit('blur')"
@blur="emit('blur')"
>
<template #item="{ props: itemProps, item }">
<VListSubheader v-if="shouldRenderSubheader(item.raw.category)">
{{ item.raw.category }}
</VListSubheader>
<VListItem v-bind="itemProps" />
</template>
</AppAutocomplete>
</div>
</template>

View File

@@ -1,13 +1,15 @@
<script setup lang="ts">
import IdentityMatchBanner from './IdentityMatchBanner.vue'
import type { FormStep } from '@/composables/useFormSteps'
import { FormFieldType } from '@/types/formBuilder'
import type { FormValues, PublicFormField } from '@/types/formBuilder'
import type { FormValues, PublicFormField, PublicFormSubmissionIdentityMatch } from '@/types/formBuilder'
const props = defineProps<{
steps: FormStep[]
values: FormValues
submitterName?: string
submitterEmail?: string
identityMatch?: PublicFormSubmissionIdentityMatch | null
}>()
function displayValue(field: PublicFormField): string {
@@ -53,6 +55,16 @@ function answerableFields(step: FormStep): PublicFormField[] {
<VDivider />
<VCardText
v-if="identityMatch"
class="pa-6 pb-0"
>
<IdentityMatchBanner
:status="identityMatch.status"
:message="identityMatch.message"
/>
</VCardText>
<VCardText class="pa-6">
<h3 class="text-subtitle-1 font-weight-medium mb-3">
Contactgegevens

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
interface Props {
status: 'pending' | 'matched' | 'none' | null
message?: string | null
}
const props = defineProps<Props>()
// Frontend fallbacks for each state — mirror the backend copy so the
// banner still renders correctly if a future response trims `message`.
// Backend `message` is authoritative (single source of truth for copy).
const FALLBACK_TITLE: Record<Exclude<Props['status'], null>, string> = {
pending: 'We controleren je gegevens',
matched: 'Gegevens gekoppeld',
none: 'Aanmelding ontvangen',
}
const FALLBACK_BODY: Record<Exclude<Props['status'], null>, string> = {
pending: 'We kijken of je al bekend bent bij de organisator. Je gegevens worden automatisch gekoppeld zodra zij dit bevestigen.',
matched: 'Je bent automatisch gekoppeld aan je bestaande account bij de organisator.',
none: 'De organisator neemt contact met je op zodra je aanmelding is verwerkt.',
}
const TYPE: Record<Exclude<Props['status'], null>, 'info' | 'success'> = {
pending: 'info',
matched: 'success',
none: 'success',
}
const body = computed(() => {
if (!props.status) return ''
const backend = (props.message ?? '').trim()
if (backend) return backend
return FALLBACK_BODY[props.status]
})
const title = computed(() => {
if (!props.status) return ''
return FALLBACK_TITLE[props.status]
})
const alertType = computed(() => {
if (!props.status) return 'info'
return TYPE[props.status]
})
</script>
<template>
<VAlert
v-if="status"
:type="alertType"
variant="tonal"
prominent
class="identity-match-banner mb-4"
>
<div class="text-subtitle-1 font-weight-medium mb-1">
{{ title }}
</div>
<div class="text-body-2">
{{ body }}
</div>
</VAlert>
</template>

View File

@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { PublicFormSectionOption } from '@/types/formBuilder'
interface ApiResponse<T> {
data: T
}
// Sibling endpoint for SECTION_PRIORITY — festival-aware and dedup-by-name
// per PublicFormController::sections (show_in_registration=true, standard).
export function usePublicFormSections(token: Ref<string>) {
return useQuery({
queryKey: ['public-form', token, 'sections'],
queryFn: async (): Promise<PublicFormSectionOption[]> => {
const t = token.value
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.get<ApiResponse<PublicFormSectionOption[]>>(
`/public/forms/${t}/sections`,
)
return data.data
},
enabled: computed(() => !!token.value),
staleTime: 1000 * 60 * 5,
})
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { PublicFormTimeSlot } from '@/types/formBuilder'
interface ApiResponse<T> {
data: T
}
// Sibling endpoint for AVAILABILITY_PICKER — festival-aware per
// PublicFormController::timeSlots (parent + children, VOLUNTEER only).
// Cached for 5 minutes; data is effectively static during a session.
export function usePublicFormTimeSlots(token: Ref<string>) {
return useQuery({
queryKey: ['public-form', token, 'time-slots'],
queryFn: async (): Promise<PublicFormTimeSlot[]> => {
const t = token.value
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.get<ApiResponse<PublicFormTimeSlot[]>>(
`/public/forms/${t}/time-slots`,
)
return data.data
},
enabled: computed(() => !!token.value),
staleTime: 1000 * 60 * 5,
})
}

View File

@@ -0,0 +1,21 @@
import type { InjectionKey, Ref } from 'vue'
import { inject, provide } from 'vue'
// Page-level provide/inject for the public form token. Sibling-endpoint
// fetches (time-slots, sections) read it instead of receiving it as a
// prop through FieldRenderer, which would couple every renderer to every
// new sibling resource.
export const PUBLIC_FORM_TOKEN_KEY: InjectionKey<Ref<string>> = Symbol('PublicFormToken')
export function providePublicFormToken(token: Ref<string>): void {
provide(PUBLIC_FORM_TOKEN_KEY, token)
}
export function usePublicFormToken(): Ref<string> {
const token = inject(PUBLIC_FORM_TOKEN_KEY)
if (!token) {
throw new Error('usePublicFormToken: no token provided. Did you forget providePublicFormToken in the page?')
}
return token
}

View File

@@ -8,6 +8,7 @@ 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 { providePublicFormToken } from '@/composables/publicFormInjection'
import { FormFieldType } from '@/types/formBuilder'
import type { FormErrorCode, PublicFormField } from '@/types/formBuilder'
@@ -29,6 +30,11 @@ const token = computed(() => {
const tokenRef = computed<string | null>(() => token.value || null)
// Provide the (always-present) string token ref to AVAILABILITY_PICKER /
// SECTION_PRIORITY renderers so they can fetch their sibling endpoints
// without prop drilling through FieldRenderer.
providePublicFormToken(token)
const schemaQuery = useFetchPublicFormSchema(tokenRef)
const draft = useFormDraft(tokenRef, {
@@ -258,6 +264,7 @@ function formatReviewValue(field: PublicFormField): string {
:values="draft.values.value"
:submitter-name="draft.submitterName.value"
:submitter-email="draft.submitterEmail.value"
:identity-match="draft.submission.value?.identity_match ?? null"
/>
<!-- Data state -->

View File

@@ -165,6 +165,30 @@ export interface PublicFormErrorBody {
errors?: Record<string, string[]>
}
export interface PublicFormTimeSlot {
id: string
name: string
date: string // YYYY-MM-DD
start_time: string // HH:MM:SS
end_time: string // HH:MM:SS
duration_hours: number | null
event_id: string
event_name: string
}
export interface PublicFormSectionOption {
id: string
name: string
category: string | null
icon: string | null
registration_description: string | null
}
export interface SectionPriorityValue {
section_id: string
priority: number
}
export type FormValues = Record<string, unknown>
export interface StartDraftBody {