Created
February 5, 2026 15:53
-
-
Save magicspon/1c42f1527d8e44e0ede88d788460fcea to your computer and use it in GitHub Desktop.
payload mega form
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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