refactor(portal): move composables, types, schemas; drop duplicates

Composables (apps/portal/src/composables → apps/app/src/composables/):
  - useFormDraft, publicFormInjection → composables/ (root, used by
    shared/public-form components)
  - api/usePublicForm, api/usePublicFormSections,
    api/usePublicFormTimeSlots → composables/api/ (no collisions)
  - api/usePortalShifts, api/usePortalProfile, api/useVolunteerRegistration
    → composables/api/portal/ (subfolder per WS-3 PR-B1 charter to
    leave room for organizer-side namesakes without clashes)
  - api/useMfa → DELETED (apps/app version is a strict superset
    with extra invalidateQueries calls and the admin-reset mutation)

Types (apps/portal/src/types → apps/app/src/types/):
  - api, portal-shift, portal, registration → moved
  - mfa → DELETED (byte-identical to apps/app/src/types/mfa.ts)

Schemas:
  - apps/portal/src/schemas/registrationSchema.ts → apps/app/src/schemas/

Utils:
  - deviceFingerprint, paginationMeta → DELETED (byte-identical
    duplicates already in apps/app/src/utils/)

Lib:
  - apps/portal/src/lib/{axios,query-client}.ts → DELETED. apps/app's
    callback-bound axios (post-PR-A) and query-client are the
    canonical versions. Portal pages currently importing
    `@/lib/axios#apiClient` resolve to apps/app's apiClient with no
    behavioral change for cookie-based requests.

Tests: 4 composable specs (useFormDraft x2, usePublicFormSections,
usePublicFormTimeSlots) moved into __tests__/ subdirs alongside
their composables.

@form-schema imports inside the moved files rewritten to
@/composables/forms/*.

Vitest now: 23 files / 162 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:08:53 +02:00
parent 4fe1a0c517
commit 7282861a7e
25 changed files with 0 additions and 373 deletions

View File

@@ -0,0 +1,267 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
vi.mock('@/lib/axios', () => {
const post = vi.fn()
const put = vi.fn()
const get = vi.fn()
return { apiClient: { post, put, get } }
})
import { apiClient } from '@/lib/axios'
import { draftSubmitterStorageKey, useFormDraft } from '@/composables/useFormDraft'
interface MockedApi {
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
get: ReturnType<typeof vi.fn>
}
const mocked = apiClient as unknown as MockedApi
function mountWithDraft(token: string, options?: Parameters<typeof useFormDraft>[1]) {
const draftRef: { value: ReturnType<typeof useFormDraft> | null } = { value: null }
const Host = defineComponent({
setup() {
draftRef.value = useFormDraft(ref(token), options)
return () => h('div')
},
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const wrapper = mount(Host, {
global: { plugins: [[VueQueryPlugin, { queryClient }]] },
})
return { wrapper, draftRef }
}
function submissionFixture(id: string) {
return {
id,
form_schema_id: 'sc_1',
status: 'draft',
auto_save_count: 0,
submitted_in_locale: 'nl',
schema_version_at_submit: null,
schema_drift: false,
values: {},
identity_match: null,
opened_at: null,
first_interacted_at: null,
submitted_at: null,
submission_duration_seconds: null,
created_at: null,
updated_at: null,
}
}
function apiSuccess<T>(body: T) {
return { data: { data: body, message: 'ok', success: true } }
}
function flush(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}
describe('useFormDraft - submitter details', () => {
const TOKEN = 'TKN-sub'
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
})
afterEach(() => {
sessionStorage.clear()
})
it('starts with empty submitterName and submitterEmail', () => {
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
expect(draft.submitterName.value).toBe('')
expect(draft.submitterEmail.value).toBe('')
wrapper.unmount()
})
it('setSubmitterName marks the draft dirty — subsequent flush includes public_submitter_name in the PUT body', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
mocked.put.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 })
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterName('Test User')
await draft.saveDraftNow()
expect(mocked.put).toHaveBeenCalledTimes(1)
const [, body] = mocked.put.mock.calls[0]
expect(body.public_submitter_name).toBe('Test User')
wrapper.unmount()
})
it('setSubmitterEmail flows through to the PUT body on flush', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
mocked.put.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 })
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterEmail('user@example.nl')
await draft.saveDraftNow()
expect(mocked.put).toHaveBeenCalledTimes(1)
const [, body] = mocked.put.mock.calls[0]
expect(body.public_submitter_email).toBe('user@example.nl')
wrapper.unmount()
})
it('submitForm sends submitter name and email in the POST /submit body', async () => {
mocked.post
.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start
.mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' })) // submit
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterName('Alice')
draft.setSubmitterEmail('alice@example.nl')
const result = await draft.submitForm()
await flush()
expect(result).toBeTruthy()
expect(mocked.post).toHaveBeenCalledTimes(2)
const [, submitBody] = mocked.post.mock.calls[1]
expect(submitBody.public_submitter_name).toBe('Alice')
expect(submitBody.public_submitter_email).toBe('alice@example.nl')
wrapper.unmount()
})
// Contract: if submitter fields are empty at submit time, submitForm
// returns null, sets submitError with code 'MISSING_SUBMITTER', and
// does NOT hit the submit endpoint — the page is expected to read
// submitError and bounce the user back to the Contactgegevens step.
it('submitForm returns null and sets submitError when submitter fields are empty', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start only
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
expect(mocked.post).toHaveBeenCalledTimes(1)
const result = await draft.submitForm()
expect(result).toBeNull()
const err = draft.submitError.value as { code?: string } | null
expect(err).toBeTruthy()
expect(err?.code).toBe('MISSING_SUBMITTER')
// Submit endpoint was NOT called — only the start POST is in the log.
expect(mocked.post).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('submitForm refuses to submit when only one of name/email is set', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterName('Bob')
// email intentionally left empty
const result = await draft.submitForm()
expect(result).toBeNull()
const err = draft.submitError.value as { code?: string } | null
expect(err?.code).toBe('MISSING_SUBMITTER')
expect(mocked.post).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('restores submitter details from sessionStorage on start and includes them in the initial POST body', async () => {
sessionStorage.setItem(
draftSubmitterStorageKey(TOKEN),
JSON.stringify({ name: 'Resumed', email: 'resumed@example.nl' }),
)
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
expect(draft.submitterName.value).toBe('Resumed')
expect(draft.submitterEmail.value).toBe('resumed@example.nl')
const [, body] = mocked.post.mock.calls[0]
expect(body.public_submitter_name).toBe('Resumed')
expect(body.public_submitter_email).toBe('resumed@example.nl')
wrapper.unmount()
})
it('persists submitter details to sessionStorage when set', () => {
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
draft.setSubmitterName('Persist Me')
draft.setSubmitterEmail('persist@example.nl')
const raw = sessionStorage.getItem(draftSubmitterStorageKey(TOKEN))
expect(raw).toBeTruthy()
const parsed = JSON.parse(raw as string)
expect(parsed).toEqual({ name: 'Persist Me', email: 'persist@example.nl' })
wrapper.unmount()
})
it('clears the submitter sessionStorage entry after a successful submit', async () => {
mocked.post
.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
.mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' }))
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
draft.setSubmitterName('Cleaner')
draft.setSubmitterEmail('cleaner@example.nl')
expect(sessionStorage.getItem(draftSubmitterStorageKey(TOKEN))).toBeTruthy()
await draft.submitForm()
await flush()
expect(sessionStorage.getItem(draftSubmitterStorageKey(TOKEN))).toBeNull()
wrapper.unmount()
})
})

View File

@@ -0,0 +1,174 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
vi.mock('@/lib/axios', () => {
const post = vi.fn()
const put = vi.fn()
const get = vi.fn()
return { apiClient: { post, put, get } }
})
import { apiClient } from '@/lib/axios'
import { draftIdempotencyKey, useFormDraft } from '@/composables/useFormDraft'
interface MockedApi {
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
get: ReturnType<typeof vi.fn>
}
const mocked = apiClient as unknown as MockedApi
function mountWithDraft(token: string, options?: Parameters<typeof useFormDraft>[1]) {
const draftRef: { value: ReturnType<typeof useFormDraft> | null } = { value: null }
const Host = defineComponent({
setup() {
draftRef.value = useFormDraft(ref(token), options)
return () => h('div')
},
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const wrapper = mount(Host, {
global: { plugins: [[VueQueryPlugin, { queryClient }]] },
})
return { wrapper, draftRef }
}
function submissionFixture(id: string) {
return {
id,
form_schema_id: 'sc_1',
status: 'draft',
auto_save_count: 0,
submitted_in_locale: 'nl',
schema_version_at_submit: null,
schema_drift: false,
values: {},
identity_match: null,
opened_at: null,
first_interacted_at: null,
submitted_at: null,
submission_duration_seconds: null,
created_at: null,
updated_at: null,
}
}
function apiSuccess<T>(body: T) {
return { data: { data: body, message: 'ok', success: true } }
}
function flush(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}
describe('useFormDraft', () => {
const TOKEN = 'TKN123'
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
})
afterEach(() => {
sessionStorage.clear()
})
it('derives the sessionStorage key from the token', () => {
expect(draftIdempotencyKey('abc')).toBe('draft_idem:abc')
})
it('generates and stores an idempotency_key on first start', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value
expect(draft).toBeTruthy()
await draft!.start()
await flush()
expect(mocked.post).toHaveBeenCalledTimes(1)
const [, body] = mocked.post.mock.calls[0]
expect(body.idempotency_key).toMatch(/^[a-f0-9]{8,30}$/i)
expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBe(body.idempotency_key)
wrapper.unmount()
})
it('reuses the idempotency_key across remounts in the same session', async () => {
mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1')))
const first = mountWithDraft(TOKEN)
await first.draftRef.value!.start()
await flush()
const firstKey = mocked.post.mock.calls[0][1].idempotency_key
first.wrapper.unmount()
mocked.post.mockClear()
mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1')))
const second = mountWithDraft(TOKEN)
await second.draftRef.value!.start()
await flush()
const secondKey = mocked.post.mock.calls[0][1].idempotency_key
expect(secondKey).toBe(firstKey)
second.wrapper.unmount()
})
it('debounces field saves and flushes on saveDraftNow', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
mocked.put.mockResolvedValue(apiSuccess({ ...submissionFixture('s1'), auto_save_count: 1 }))
const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 })
const draft = draftRef.value!
await draft.start()
await flush()
expect(mocked.put).not.toHaveBeenCalled()
draft.saveField('name', 'Alice')
draft.saveField('name', 'Alice B')
expect(mocked.put).not.toHaveBeenCalled()
await draft.saveDraftNow()
expect(mocked.put).toHaveBeenCalledTimes(1)
const [, body] = mocked.put.mock.calls[0]
expect(body.values).toEqual({ name: 'Alice B' })
wrapper.unmount()
})
it('clears the sessionStorage key after a successful submit', async () => {
mocked.post
.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start draft
.mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' })) // submit
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBeTruthy()
// submitForm guards against empty submitter fields — populate them
// so the composable actually hits the submit endpoint.
draft.setSubmitterName('Test')
draft.setSubmitterEmail('test@example.nl')
const submitted = await draft.submitForm()
await flush()
expect(submitted?.status).toBe('submitted')
expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBeNull()
wrapper.unmount()
})
})

View File

@@ -0,0 +1,83 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
vi.mock('@/lib/axios', () => ({
apiClient: { get: vi.fn() },
}))
import { apiClient } from '@/lib/axios'
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
import type { PublicFormSectionOption } from '@form-schema/types/formBuilder'
interface MockedApi { get: ReturnType<typeof vi.fn> }
const mocked = apiClient as unknown as MockedApi
function mountHook(tokenValue: string) {
const result: { query: ReturnType<typeof usePublicFormSections> | null } = { query: null }
const Host = defineComponent({
setup() {
result.query = usePublicFormSections(ref(tokenValue))
return () => h('div')
},
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = mount(Host, {
global: { plugins: [[VueQueryPlugin, { queryClient }]] },
})
return { wrapper, result }
}
function section(partial: Partial<PublicFormSectionOption> = {}): PublicFormSectionOption {
return {
id: partial.id ?? '01B',
name: partial.name ?? 'Bar',
category: partial.category ?? 'Horeca',
icon: partial.icon ?? 'tabler-beer',
registration_description: partial.registration_description ?? 'Tappen en serveren',
}
}
function flush(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}
describe('usePublicFormSections', () => {
beforeEach(() => { vi.clearAllMocks() })
afterEach(() => { vi.clearAllMocks() })
it('fetches and parses PublicFormSectionOption[] on happy path', async () => {
const s = section()
mocked.get.mockResolvedValueOnce({ data: { data: [s] } })
const { result } = mountHook('TKN42')
await flush()
await flush()
expect(mocked.get).toHaveBeenCalledWith('/public/forms/TKN42/sections')
expect(result.query?.data.value).toEqual([s])
})
it('is disabled when the token ref is empty', async () => {
const { result } = mountHook('')
await flush()
expect(mocked.get).not.toHaveBeenCalled()
expect(result.query?.isFetching.value).toBe(false)
})
it('surfaces errors via isError', async () => {
mocked.get.mockRejectedValueOnce(new Error('boom'))
const { result } = mountHook('TKN99')
await flush()
await flush()
expect(result.query?.isError.value).toBe(true)
})
})

View File

@@ -0,0 +1,86 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
vi.mock('@/lib/axios', () => ({
apiClient: { get: vi.fn() },
}))
import { apiClient } from '@/lib/axios'
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
import type { PublicFormTimeSlot } from '@form-schema/types/formBuilder'
interface MockedApi { get: ReturnType<typeof vi.fn> }
const mocked = apiClient as unknown as MockedApi
function mountHook(tokenValue: string) {
const result: { query: ReturnType<typeof usePublicFormTimeSlots> | null } = { query: null }
const Host = defineComponent({
setup() {
result.query = usePublicFormTimeSlots(ref(tokenValue))
return () => h('div')
},
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = mount(Host, {
global: { plugins: [[VueQueryPlugin, { queryClient }]] },
})
return { wrapper, result }
}
function timeSlot(partial: Partial<PublicFormTimeSlot> = {}): PublicFormTimeSlot {
return {
id: partial.id ?? '01A',
name: partial.name ?? 'Zaterdag middag',
date: partial.date ?? '2026-07-11',
start_time: partial.start_time ?? '12:00:00',
end_time: partial.end_time ?? '18:00:00',
duration_hours: partial.duration_hours ?? 6,
event_id: partial.event_id ?? 'evt_1',
event_name: partial.event_name ?? 'Echt Feesten 2026',
}
}
function flush(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}
describe('usePublicFormTimeSlots', () => {
beforeEach(() => { vi.clearAllMocks() })
afterEach(() => { vi.clearAllMocks() })
it('fetches and parses PublicFormTimeSlot[] on happy path', async () => {
const slot = timeSlot()
mocked.get.mockResolvedValueOnce({ data: { data: [slot] } })
const { result } = mountHook('TKN42')
await flush()
await flush()
expect(mocked.get).toHaveBeenCalledWith('/public/forms/TKN42/time-slots')
expect(result.query?.data.value).toEqual([slot])
})
it('is disabled when the token ref is empty', async () => {
const { result } = mountHook('')
await flush()
expect(mocked.get).not.toHaveBeenCalled()
expect(result.query?.isFetching.value).toBe(false)
})
it('surfaces errors via isError', async () => {
mocked.get.mockRejectedValueOnce(new Error('network'))
const { result } = mountHook('TKN99')
await flush()
await flush()
expect(result.query?.isError.value).toBe(true)
})
})

View File

@@ -0,0 +1,47 @@
import { useMutation } from '@tanstack/vue-query'
import { apiClient } from '@/lib/axios'
interface ApiResponse<T> {
data: T
}
export interface ProfileUpdatePayload {
event_id: string
first_name?: string
last_name?: string
phone?: string | null
date_of_birth?: string | null
remarks?: string | null
}
export interface PasswordUpdatePayload {
current_password: string
password: string
password_confirmation: string
}
export function useUpdateProfile() {
return useMutation({
mutationFn: async (payload: ProfileUpdatePayload) => {
const { data } = await apiClient.put<ApiResponse<{ message: string }>>(
'/portal/profile',
payload,
)
return data.data
},
})
}
export function useUpdatePassword() {
return useMutation({
mutationFn: async (payload: PasswordUpdatePayload) => {
const { data } = await apiClient.put<ApiResponse<{ message: string }>>(
'/portal/password',
payload,
)
return data.data
},
})
}

View File

@@ -0,0 +1,86 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AllMyShiftsEventGroup, AvailableShiftsDay, MyShiftsResponse } from '@/types/portal-shift'
interface ApiResponse<T> {
data: T
}
export function useAllMyShifts() {
return useQuery({
queryKey: ['portal-all-my-shifts'],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<AllMyShiftsEventGroup[]>>(
'/portal/my-shifts',
)
return data.data
},
})
}
export function useAvailableShifts(eventId: Ref<string | null>) {
return useQuery({
queryKey: ['available-shifts', eventId],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<AvailableShiftsDay[]>>(
`/portal/events/${eventId.value}/available-shifts`,
)
return data.data
},
enabled: () => !!eventId.value,
})
}
export function useMyShifts(eventId: Ref<string | null>) {
return useQuery({
queryKey: ['my-shifts', eventId],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<MyShiftsResponse>>(
`/portal/events/${eventId.value}/my-shifts`,
)
return data.data
},
enabled: () => !!eventId.value,
})
}
export function useClaimShift(eventId: Ref<string | null>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (shiftId: string) => {
const { data } = await apiClient.post<ApiResponse<{ assignment_id: string; status: string; message: string }>>(
`/portal/events/${eventId.value}/shifts/${shiftId}/claim`,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['available-shifts', eventId] })
queryClient.invalidateQueries({ queryKey: ['my-shifts', eventId] })
},
})
}
export function useCancelAssignment(eventId: Ref<string | null>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ assignmentId, reason }: { assignmentId: string; reason?: string }) => {
const { data } = await apiClient.post<ApiResponse<{ message: string }>>(
`/portal/events/${eventId.value}/assignments/${assignmentId}/cancel`,
reason ? { reason } : {},
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['available-shifts', eventId] })
queryClient.invalidateQueries({ queryKey: ['my-shifts', eventId] })
},
})
}

View File

@@ -0,0 +1,40 @@
import { useQuery, useMutation } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { EventRegistrationData, VolunteerRegistrationForm } from '@/types/registration'
interface ApiResponse<T> {
data: T
}
export function useRegistrationData(eventSlug: Ref<string>) {
return useQuery({
queryKey: ['registration-data', eventSlug],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<EventRegistrationData>>(
`/public/events/${eventSlug.value}/registration-data`,
)
return data.data
},
enabled: () => !!eventSlug.value,
retry: false,
})
}
export interface VolunteerRegistrationResponse {
person: Record<string, unknown>
}
export function useSubmitRegistration() {
return useMutation({
mutationFn: async ({ eventId, form }: { eventId: string; form: VolunteerRegistrationForm }) => {
const { data } = await apiClient.post<ApiResponse<VolunteerRegistrationResponse>>(
`/events/${eventId}/volunteer-register`,
form,
)
return data.data
},
})
}

View File

@@ -0,0 +1,110 @@
import { useMutation, useQuery } from '@tanstack/vue-query'
import type { AxiosError } from 'axios'
import type { MaybeRefOrGetter } from 'vue'
import { apiClient } from '@/lib/axios'
import type {
PublicFormErrorBody,
PublicFormSchema,
PublicFormSubmission,
SaveDraftBody,
StartDraftBody,
SubmitBody,
} from '@form-schema/types/formBuilder'
interface ApiResponse<T> {
data: T
message?: string
success?: boolean
}
/**
* The backend standardises public-form errors as
* { message, code?, errors? }
* See api/app/Exceptions/FormBuilder/PublicFormApiException.php.
*/
export type PublicFormAxiosError = AxiosError<PublicFormErrorBody>
const TERMINAL_STATUSES = new Set([404, 410, 409])
export function useFetchPublicFormSchema(token: MaybeRefOrGetter<string | null | undefined>) {
return useQuery({
queryKey: ['public-form-schema', () => toValue(token)],
queryFn: async (): Promise<PublicFormSchema> => {
const t = toValue(token)
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.get<ApiResponse<PublicFormSchema>>(`/public/forms/${t}`)
return data.data
},
enabled: () => !!toValue(token),
retry: (failureCount, error) => {
const status = (error as PublicFormAxiosError | undefined)?.response?.status
if (status && TERMINAL_STATUSES.has(status)) return false
return failureCount < 1
},
staleTime: 1000 * 60 * 5,
})
}
export function useCreateFormDraft(token: MaybeRefOrGetter<string | null | undefined>) {
return useMutation({
mutationFn: async (body: StartDraftBody): Promise<PublicFormSubmission> => {
const t = toValue(token)
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.post<ApiResponse<PublicFormSubmission>>(
`/public/forms/${t}/submissions`,
body,
)
return data.data
},
})
}
export function useSaveFormDraft(token: MaybeRefOrGetter<string | null | undefined>) {
return useMutation({
mutationFn: async ({ submissionId, body }: { submissionId: string; body: SaveDraftBody }): Promise<PublicFormSubmission> => {
const t = toValue(token)
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.put<ApiResponse<PublicFormSubmission>>(
`/public/forms/${t}/submissions/${submissionId}`,
body,
)
return data.data
},
})
}
export function useSubmitForm(token: MaybeRefOrGetter<string | null | undefined>) {
return useMutation({
mutationFn: async ({ submissionId, body }: { submissionId: string; body: SubmitBody }): Promise<PublicFormSubmission> => {
const t = toValue(token)
if (!t) throw new Error('Missing public_token')
const { data } = await apiClient.post<ApiResponse<PublicFormSubmission>>(
`/public/forms/${t}/submissions/${submissionId}/submit`,
body,
)
return data.data
},
})
}
export function extractErrorBody(err: unknown): PublicFormErrorBody | null {
const axiosErr = err as PublicFormAxiosError | undefined
const body = axiosErr?.response?.data
if (body && typeof body === 'object' && 'message' in body) return body as PublicFormErrorBody
return null
}
export function extractRetryAfterSeconds(err: unknown): number | null {
const axiosErr = err as PublicFormAxiosError | undefined
const raw = axiosErr?.response?.headers?.['retry-after']
if (!raw) return null
const n = Number(raw)
return Number.isFinite(n) && n >= 0 ? n : null
}

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 '@form-schema/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 '@form-schema/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,37 @@
import type { InjectionKey, Ref } from 'vue'
import { computed, 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
}
// Page-level provide/inject for the active form locale. Used by
// option-bearing field renderers (FieldRadio / FieldSelect /
// FieldMultiselect / FieldCheckboxList) to resolve per-option
// translations[locale] over the default option label (WS-5d §17.6).
// Falls back to 'nl' (Crewli's default schema locale) when no provider
// is on the tree — keeps standalone component tests light.
export const PUBLIC_FORM_LOCALE_KEY: InjectionKey<Ref<string>> = Symbol('PublicFormLocale')
export function providePublicFormLocale(locale: Ref<string>): void {
provide(PUBLIC_FORM_LOCALE_KEY, locale)
}
export function usePublicFormLocale(): Ref<string> {
return inject(PUBLIC_FORM_LOCALE_KEY, computed(() => 'nl'))
}

View File

@@ -0,0 +1,356 @@
import { ref, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import {
extractErrorBody,
extractRetryAfterSeconds,
useCreateFormDraft,
useSaveFormDraft,
useSubmitForm,
} from '@/composables/api/usePublicForm'
import type { FormValues, PublicFormSubmission, SaveDraftBody } from '@form-schema/types/formBuilder'
/** sessionStorage key for reusing an idempotency key across reloads. */
export function draftIdempotencyKey(token: string): string {
return `draft_idem:${token}`
}
/** sessionStorage key for resuming submitter name/email across reloads. */
export function draftSubmitterStorageKey(token: string): string {
return `draft_submitter:${token}`
}
function generateIdempotencyKey(): string {
const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (arr: Uint8Array) => Uint8Array } }).crypto
if (c?.randomUUID) {
// UUID v4 (36 chars) exceeds backend max:30. Backend expects 6..30
// chars so compress to 24 hex chars (still collision-resistant).
return c.randomUUID().replace(/-/g, '').slice(0, 24)
}
if (c?.getRandomValues) {
const buf = new Uint8Array(12)
c.getRandomValues(buf)
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('')
}
// Last-resort fallback — still within 6..30.
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`.slice(0, 30)
}
interface UseFormDraftOptions {
/** Preferred locale string for `submitted_in_locale` (e.g. `"nl"`). */
locale?: string
/** Debounce for auto-save after a field blur, in ms. */
debounceMs?: number
}
export type SubmitterClientError = Error & { code: 'MISSING_SUBMITTER' }
export interface UseFormDraftReturn {
submission: Ref<PublicFormSubmission | null>
values: Ref<FormValues>
submitterName: Ref<string>
submitterEmail: Ref<string>
isStarting: Ref<boolean>
isSaving: Ref<boolean>
isSubmitting: Ref<boolean>
lastSavedAt: Ref<Date | null>
saveError: Ref<unknown>
submitError: Ref<unknown>
start: () => Promise<PublicFormSubmission | null>
setValue: (slug: string, value: unknown) => void
saveField: (slug: string, value: unknown) => void
setSubmitterName: (v: string) => void
setSubmitterEmail: (v: string) => void
saveDraftNow: () => Promise<void>
submitForm: () => Promise<PublicFormSubmission | null>
clearSession: () => void
}
export function useFormDraft(
token: Ref<string | null | undefined>,
options: UseFormDraftOptions = {},
): UseFormDraftReturn {
const debounceMs = options.debounceMs ?? 800
const submission = ref<PublicFormSubmission | null>(null)
const values = ref<FormValues>({})
const dirty = ref<FormValues>({})
const submitterName = ref<string>('')
const submitterEmail = ref<string>('')
const submitterDirty = ref(false)
const isStarting = ref(false)
const isSaving = ref(false)
const isSubmitting = ref(false)
const lastSavedAt = ref<Date | null>(null)
const saveError = ref<unknown>(null)
const submitError = ref<unknown>(null)
const firstInteractedAt = ref<string | null>(null)
const { mutateAsync: startDraft } = useCreateFormDraft(token)
const { mutateAsync: saveDraft } = useSaveFormDraft(token)
const { mutateAsync: submitMutation } = useSubmitForm(token)
function readStoredKey(): string | null {
const t = token.value
if (!t) return null
try {
return sessionStorage.getItem(draftIdempotencyKey(t))
}
catch {
return null
}
}
function writeStoredKey(value: string): void {
const t = token.value
if (!t) return
try {
sessionStorage.setItem(draftIdempotencyKey(t), value)
}
catch {
// storage disabled — drafts won't survive reload
}
}
function persistSubmitter(): void {
const t = token.value
if (!t) return
try {
sessionStorage.setItem(
draftSubmitterStorageKey(t),
JSON.stringify({ name: submitterName.value, email: submitterEmail.value }),
)
}
catch {
// storage disabled
}
}
function restoreSubmitter(): void {
const t = token.value
if (!t) return
try {
const raw = sessionStorage.getItem(draftSubmitterStorageKey(t))
if (!raw) return
const parsed = JSON.parse(raw) as { name?: unknown; email?: unknown }
if (typeof parsed.name === 'string') submitterName.value = parsed.name
if (typeof parsed.email === 'string') submitterEmail.value = parsed.email
}
catch {
// malformed — ignore
}
}
function clearSession(): void {
const t = token.value
if (!t) return
try {
sessionStorage.removeItem(draftIdempotencyKey(t))
sessionStorage.removeItem(draftSubmitterStorageKey(t))
}
catch {
// noop
}
}
async function start(): Promise<PublicFormSubmission | null> {
if (!token.value) return null
if (submission.value) return submission.value
isStarting.value = true
try {
// Hydrate submitter state from a prior session *before* posting the
// draft so the first create call already carries any name/email the
// user had typed on a previous mount.
restoreSubmitter()
let key = readStoredKey()
if (!key) {
key = generateIdempotencyKey()
writeStoredKey(key)
}
const created = await startDraft({
idempotency_key: key,
opened_at: new Date().toISOString(),
submitted_in_locale: options.locale,
public_submitter_name: submitterName.value || undefined,
public_submitter_email: submitterEmail.value || undefined,
})
submission.value = created
const hydrated: FormValues = {}
for (const [slug, entry] of Object.entries(created.values ?? {})) {
hydrated[slug] = entry?.value
}
values.value = { ...hydrated, ...values.value }
return created
}
finally {
isStarting.value = false
}
}
function setValue(slug: string, value: unknown): void {
values.value = { ...values.value, [slug]: value }
dirty.value = { ...dirty.value, [slug]: value }
}
function setSubmitterName(v: string): void {
submitterName.value = v
submitterDirty.value = true
persistSubmitter()
}
function setSubmitterEmail(v: string): void {
submitterEmail.value = v
submitterDirty.value = true
persistSubmitter()
}
function markInteracted(): void {
if (!firstInteractedAt.value) firstInteractedAt.value = new Date().toISOString()
}
async function flushDirty(): Promise<void> {
if (!submission.value) return
const keys = Object.keys(dirty.value)
const hadSubmitterDirty = submitterDirty.value
if (keys.length === 0 && !hadSubmitterDirty) return
const snapshot = { ...dirty.value }
dirty.value = {}
submitterDirty.value = false
isSaving.value = true
saveError.value = null
try {
const body: SaveDraftBody = {
first_interacted_at: firstInteractedAt.value ?? undefined,
public_submitter_name: submitterName.value || undefined,
public_submitter_email: submitterEmail.value || undefined,
}
if (keys.length > 0) body.values = snapshot
const updated = await saveDraft({
submissionId: submission.value.id,
body,
})
submission.value = updated
lastSavedAt.value = new Date()
}
catch (err) {
// Restore the dirty set so the next save retries these values.
dirty.value = { ...snapshot, ...dirty.value }
if (hadSubmitterDirty) submitterDirty.value = true
saveError.value = err
}
finally {
isSaving.value = false
}
}
function saveField(slug: string, value: unknown): void {
markInteracted()
setValue(slug, value)
}
async function saveDraftNow(): Promise<void> {
markInteracted()
if (!submission.value) await start()
await flushDirty()
}
// Debounced background save whenever the dirty surface changes — values
// or submitter.
watchDebounced(
() => Object.keys(dirty.value).length + (submitterDirty.value ? 1 : 0),
count => {
if (count > 0 && submission.value) void flushDirty()
},
{ debounce: debounceMs },
)
// Abandon draft session when token changes (hot route swap / dev).
watch(token, () => {
submission.value = null
values.value = {}
dirty.value = {}
submitterName.value = ''
submitterEmail.value = ''
submitterDirty.value = false
lastSavedAt.value = null
})
async function submitForm(): Promise<PublicFormSubmission | null> {
submitError.value = null
// Submitter name/email are required at final submit. Surface a
// client-side error without hitting the endpoint so the page can
// bounce the user back to the Contactgegevens step.
const name = submitterName.value.trim()
const email = submitterEmail.value.trim()
if (!name || !email) {
const err = new Error('Submitter name and email are required.') as SubmitterClientError
err.code = 'MISSING_SUBMITTER'
submitError.value = err
return null
}
if (!submission.value) {
await start()
if (!submission.value) return null
}
// Flush any pending auto-save so the submit merges with server state.
await flushDirty()
isSubmitting.value = true
try {
const submitted = await submitMutation({
submissionId: submission.value.id,
body: {
values: values.value,
public_submitter_name: submitterName.value,
public_submitter_email: submitterEmail.value,
},
})
submission.value = submitted
clearSession()
return submitted
}
catch (err) {
submitError.value = err
const body = extractErrorBody(err)
if (body?.code === 'SUBMISSION_ALREADY_SUBMITTED') clearSession()
return null
}
finally {
isSubmitting.value = false
}
}
return {
submission,
values,
submitterName,
submitterEmail,
isStarting,
isSaving,
isSubmitting,
lastSavedAt,
saveError,
submitError,
start,
setValue,
saveField,
setSubmitterName,
setSubmitterEmail,
saveDraftNow,
submitForm,
clearSession,
}
}
export { extractErrorBody, extractRetryAfterSeconds }