Skip to content

Instantly share code, notes, and snippets.

@magicspon
Created February 5, 2026 15:53
Show Gist options
  • Select an option

  • Save magicspon/1c42f1527d8e44e0ede88d788460fcea to your computer and use it in GitHub Desktop.

Select an option

Save magicspon/1c42f1527d8e44e0ede88d788460fcea to your computer and use it in GitHub Desktop.
payload mega form
import { FieldRender } from './FieldRender'
import { buildDefaultValues, createZodSchema } from './form.utils'
import { useConditionalFields } from './useConditionalFields'
import { RichText } from '@payloadcms/richtext-lexical/react'
import type { Form as FormProps } from '@spon/payload-types'
import { Stack } from '@spon/ui/layout/Stack'
import { useForm } from '@tanstack/react-form'
import { useMutation } from '@tanstack/react-query'
import * as React from 'react'
import { withQueryProvider } from '~/providers/QueryProvider'
import { fetcher } from '~/utils/fetcher'
export type TFormProps = Pick<
FormProps,
| 'id'
| 'title'
| 'pages'
| 'formSchema'
| 'confirmationType'
| 'confirmationMessage'
| 'submitButtonLabel'
| 'redirect'
>
function FormComponent({
title,
id,
pages,
formSchema,
submitButtonLabel = 'Submit',
confirmationMessage,
redirect,
confirmationType,
}: TFormProps) {
const [currentPageIndex, setCurrentPageIndex] = React.useState(0)
const [submitStatus, setSubmitStatus] = React.useState<
'idle' | 'success' | 'error'
>('idle')
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
// Honeypot field ref for spam protection
const honeypotRef = React.useRef<HTMLInputElement>(null)
// Timestamp for time-based spam protection
const formLoadTime = React.useRef(Date.now())
const mutation = useMutation<Response, Error, FormData>({
mutationKey: ['submission', id],
mutationFn: (input) =>
fetcher<Response>('/submit-form', {
method: 'POST',
body: input,
}),
onSuccess: () => {
setSubmitStatus('success')
if (confirmationType === 'redirect') {
window.location.href = redirect ?? '/'
}
},
onError: (error) => {
setSubmitStatus('error')
setErrorMessage(error.message)
},
})
const defaultValues = buildDefaultValues(pages)
const form = useForm({
defaultValues,
validators: {
onChange: createZodSchema(formSchema),
},
onSubmit: async ({ value }) => {
const submissionData: Record<string, unknown> = {}
const from = (value?.email as string) ?? ''
const formData = new FormData()
formData.append('id', id)
formData.append('title', title)
formData.append('from', from)
formData.append('_hp', honeypotRef.current?.value ?? '')
formData.append('_ts', String(formLoadTime.current))
// Separate files from regular form values
for (const [fieldName, fieldValue] of Object.entries(value)) {
if (
Array.isArray(fieldValue) &&
fieldValue.length > 0 &&
fieldValue[0] instanceof File
) {
// Store file metadata in submissionData
const fileMeta = (fieldValue as File[]).map((file) => ({
name: file.name,
size: file.size,
type: file.type,
}))
submissionData[fieldName] = fileMeta
// Append each file to FormData
for (const file of fieldValue as File[]) {
formData.append(fieldName, file)
}
} else {
submissionData[fieldName] = fieldValue
}
}
formData.append('submissionData', JSON.stringify(submissionData))
mutation.mutate(formData)
},
})
const isFieldVisible = useConditionalFields(pages, form)
if (!pages || pages.length === 0) {
return <p>No form pages configured</p>
}
if (
submitStatus === 'success' &&
confirmationMessage &&
confirmationType === 'message'
) {
return (
<div className="rounded-md bg-green-50 p-4">
<RichText data={confirmationMessage} />
</div>
)
}
const currentPage = pages[currentPageIndex]
const isFirstPage = currentPageIndex === 0
const isLastPage = currentPageIndex === pages.length - 1
const isMultiPage = pages.length > 1
const handleNext = () => {
if (!isLastPage) {
setCurrentPageIndex((prev) => prev + 1)
}
}
const handleBack = () => {
if (!isFirstPage) {
setCurrentPageIndex((prev) => prev - 1)
}
}
return (
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
if (isLastPage) {
form.handleSubmit()
} else {
handleNext()
}
}}
>
{/* Honeypot field - hidden from users, bots will fill it */}
<input
ref={honeypotRef}
type="text"
name="website_url"
autoComplete="off"
tabIndex={-1}
aria-hidden="true"
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
/>
{submitStatus === 'error' && errorMessage && (
<div className="rounded-md bg-red-50 p-4 mb-4">
<p className="text-sm font-medium text-red-800">{errorMessage}</p>
</div>
)}
{isMultiPage && (
<div className="mb-4">
<h2 className="text-lg font-semibold">{currentPage.title}</h2>
<p className="text-sm text-gray-500">
Page {currentPageIndex + 1} of {pages.length}
</p>
</div>
)}
<Stack className="gap-5">
{currentPage.rows.map((row) => (
<div
key={row.id}
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${row.columns.length}, minmax(0, 1fr))`,
}}
>
{row.columns.map((field) => {
// Check visibility for message fields (use id since they don't have name)
if (field.type === 'message') {
if (!isFieldVisible(field.id)) {
return null
}
return (
<div key={field.id} className="prose prose-sm">
{/* TODO: Render rich text content */}
<p className="text-gray-600 italic">Message field</p>
</div>
)
}
// Check visibility for input fields
if (!isFieldVisible(field.name)) {
return null
}
return (
<form.Field key={field.id} name={field.name}>
{(fieldApi) => {
return (
<div className="space-y-1">
<label
htmlFor={field.id}
className="block text-sm font-medium text-gray-700"
>
{field.label}
{field.required && (
<span className="text-red-500 ml-1">*</span>
)}
</label>
{field.instructions && (
<p className="text-sm text-gray-500">
{field.instructions}
</p>
)}
<FieldRender field={field} fieldApi={fieldApi} />
{fieldApi.state.meta.errors.length > 0 && (
<p className="text-sm text-red-600">
{field.errorMessage ?? 'This field is required'}
</p>
)}
</div>
)
}}
</form.Field>
)
})}
</div>
))}
</Stack>
<div className="flex justify-between pt-4">
{isMultiPage && !isFirstPage ? (
<button
type="button"
onClick={handleBack}
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{currentPage.backButton}
</button>
) : (
<div />
)}
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
>
{isSubmitting
? 'Submitting...'
: isLastPage
? submitButtonLabel
: currentPage.nextButton}
</button>
)}
</form.Subscribe>
</div>
</form>
)
}
export const Form = withQueryProvider(FormComponent)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment