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,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
}