feat: add petition form config and server-action submission flow

- add  group to  collection with title, subtitle, and required consent relationship
- refactor  to:
  - use a dedicated shared zod schema
  - submit via server action (create contact + signature)
  - register RHF fields and handle pending/error states
  - load city options from selected state
- add server action
- add shared schema
- replace state source in  collection: derive select options from
- make  immutable with  and remove obsolete
- update generated payload types for petition  and consent references
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-02-22 23:08:00 -03:00
parent 14961c5fea
commit dac78163c3
8 changed files with 192 additions and 108 deletions

View file

@ -0,0 +1,32 @@
'use server'
import config from '@payload-config'
import { petitionFormSchema, type PetitionFormInput } from '@/lib/schemas/petition-form'
import { getPayload } from 'payload'
type SubmitPetitionSignatureInput = PetitionFormInput & {
petitionId: string
consentId: number
}
export const submitPetitionSignature = async (input: SubmitPetitionSignatureInput) => {
const { petitionId, consentId, ...contactInput } = input
const data = petitionFormSchema.parse(contactInput)
const payload = await getPayload({ config })
const contact = await payload.create({
collection: 'contact',
data,
})
await payload.create({
collection: 'signature',
data: {
contact: contact.id,
petition: petitionId,
consent: consentId,
},
})
return { ok: true as const }
}

View file

@ -1,4 +1,4 @@
import { STATES } from '@/lib/states'
import { CitiesByState } from '@/lib/cities'
import { CollectionConfig } from 'payload'
export const Contact: CollectionConfig = {
@ -38,7 +38,7 @@ export const Contact: CollectionConfig = {
name: 'state',
type: 'select',
label: 'Estado',
options: STATES,
options: Object.keys(CitiesByState) as (keyof typeof CitiesByState)[],
required: true,
},
{

View file

@ -62,5 +62,30 @@ export const Petition: CollectionConfig<typeof slug> = {
label: 'Corpo',
required: true,
},
{
name: 'form',
type: 'group',
label: 'Formulário',
required: true,
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
},
{
name: 'subtitle',
type: 'text',
label: 'Subtítulo',
},
{
name: 'consent',
type: 'relationship',
label: 'Consentimento',
relationTo: 'consent',
required: true,
},
],
},
],
}

View file

@ -11,22 +11,14 @@ import {
import { Input } from '@/components/ui/input'
import { Petition } from '@/payload-types'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import z from 'zod'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select'
import { STATES } from '@/lib/states'
import { type SubmitHandler, useForm } from 'react-hook-form'
import { NativeSelect, NativeSelectOption } from './ui/native-select'
import { ReactEventHandler, useEffect, useState } from 'react'
import { useState, useTransition } from 'react'
import { CitiesByState } from '@/lib/cities'
import { Textarea } from './ui/textarea'
import { Button } from './ui/button'
import { petitionFormSchema, type PetitionFormInput } from '@/lib/schemas/petition-form'
import { submitPetitionSignature } from '@/app/(frontend)/actions/submitPetitionSignature'
interface PetitionFormProps {
id: string
@ -34,47 +26,64 @@ interface PetitionFormProps {
}
export const PetitionForm = ({ id, petition }: PetitionFormProps) => {
const methods = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const methods = useForm<PetitionFormInput>({
resolver: zodResolver(petitionFormSchema),
defaultValues: {
name: '',
email: '',
phone: '',
state: '',
state: undefined,
city: '',
postalCode: '',
comment: '',
},
})
const [cityOptions, setCityOptions] = useState<string[]>([])
const [isSubmitting, startTransition] = useTransition()
const handleStateUpdate: ReactEventHandler<HTMLSelectElement> = (e) => {
if (!e.currentTarget.value) {
const [cityOptions, setCityOptions] = useState<readonly string[]>([])
const handleStateUpdate = (stateValue?: keyof typeof CitiesByState) => {
if (!stateValue) {
setCityOptions([])
methods.setValue('city', '')
return
}
const state = e.currentTarget.value as keyof typeof CitiesByState
setCityOptions(CitiesByState[state])
console.log('ran')
setCityOptions(CitiesByState[stateValue])
methods.setValue('city', '')
}
console.log(cityOptions)
const onSubmit: SubmitHandler<PetitionFormInput> = (input) => {
const consentId =
typeof petition.form?.consent === 'number' ? petition.form.consent : petition.form.consent.id
const onSubmit = (data: z.infer<typeof formSchema>) => {
console.log(data)
startTransition(async () => {
try {
await submitPetitionSignature({
...input,
petitionId: petition.id,
consentId,
})
methods.reset()
setCityOptions([])
} catch {
methods.setError('root', {
message: 'Falha ao enviar assinatura. Tente novamente.',
})
}
})
}
return (
<form id={id} onSubmit={methods.handleSubmit(onSubmit)}>
<FieldSet>
<FieldLegend>Revogar concessão da orla de Salvador</FieldLegend>
<FieldDescription>
Assine esta petição e ajude a manter a orla de Salvador para todos nós!
</FieldDescription>
{petition.form.title ? <FieldLegend>{petition.form.title}</FieldLegend> : null}
{petition.form.subtitle ? (
<FieldDescription>
Assine esta petição e ajude a manter a orla de Salvador para todos nós!
</FieldDescription>
) : null}
<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Nome Completo</FieldLabel>
@ -84,6 +93,7 @@ export const PetitionForm = ({ id, petition }: PetitionFormProps) => {
placeholder="Digite seu nome completo"
autoComplete="name"
maxLength={120}
{...methods.register('name')}
required
/>
</Field>
@ -96,6 +106,7 @@ export const PetitionForm = ({ id, petition }: PetitionFormProps) => {
inputMode="email"
type="text"
maxLength={254}
{...methods.register('email')}
required
/>
</Field>
@ -108,12 +119,16 @@ export const PetitionForm = ({ id, petition }: PetitionFormProps) => {
inputMode="tel"
type="tel"
maxLength={15}
{...methods.register('phone')}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="state">Estado</FieldLabel>
<NativeSelect name="state" onChange={handleStateUpdate} required>
<NativeSelect
required
{...methods.register('state', { onChange: (e) => handleStateUpdate(e.target.value) })}
>
<NativeSelectOption value="">Selecione um estado</NativeSelectOption>
{Object.keys(CitiesByState).map((state) => (
<NativeSelectOption key={state} value={state}>
@ -124,7 +139,12 @@ export const PetitionForm = ({ id, petition }: PetitionFormProps) => {
</Field>
<Field>
<FieldLabel htmlFor="city">Cidade</FieldLabel>
<NativeSelect name="city" disabled={!cityOptions.length} required>
<NativeSelect
id="city"
{...methods.register('city')}
disabled={!cityOptions.length}
required
>
<NativeSelectOption value="">Selecione uma cidade</NativeSelectOption>
{cityOptions.map((city) => (
<NativeSelectOption key={city} value={city}>
@ -135,48 +155,32 @@ export const PetitionForm = ({ id, petition }: PetitionFormProps) => {
</Field>
<Field>
<FieldLabel htmlFor="postalCode">CEP</FieldLabel>
<Input id="postalCode" type="text" placeholder="00000-000" />
<Input
id="postalCode"
type="text"
placeholder="00000-000"
{...methods.register('postalCode')}
/>
</Field>
<Field>
<FieldLabel htmlFor="comment">Comentário</FieldLabel>
<Textarea />
<Textarea id="comment" {...methods.register('comment')} />
</Field>
<Field>
<Button type="submit">Assinar</Button>
{typeof petition.form.consent !== 'number' ? (
<FieldDescription>{petition.form.consent.text}</FieldDescription>
) : null}
{methods.formState.errors.root?.message ? (
<FieldDescription className="text-destructive">
{methods.formState.errors.root.message}
</FieldDescription>
) : null}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Enviando...' : 'Assinar'}
</Button>
</Field>
</FieldGroup>
</FieldSet>
</form>
)
}
const formSchema = z.object({
name: z
.string()
.trim()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
.max(120, 'Nome deve ter no máximo 100 caracteres')
.regex(
/^(?=.* )[\p{L}\p{M}]+(?:[- ][\p{L}\p{M}]+)*$/u,
'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.',
),
email: z.email('Email inválido'),
phone: z
.string()
.trim()
.length(11, 'Telefone celular inválido')
.regex(/^\d{11}$/, 'Telefone celular inválido'),
state: z.literal(STATES, 'Estado inválido'),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
postalCode: z
.string()
.trim()
.regex(/^(?:\d{8})?$/, 'CEP inválido')
.optional(),
comment: z
.string()
.trim()
.max(1000, 'Comentário muito longo')
.refine((v) => !/<\/?[a-z][\s\S]*>/i.test(v), 'O comentário não pode conter HTML')
.optional(),
})

View file

@ -5648,4 +5648,4 @@ export const CitiesByState = {
'Wanderlândia',
'Xambioá',
],
}
} as const

View file

@ -0,0 +1,40 @@
import { CitiesByState } from '@/lib/cities'
import { z } from 'zod'
type StateKey = keyof typeof CitiesByState
export const petitionFormSchema = z.object({
name: z
.string()
.trim()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
.max(120, 'Nome deve ter no máximo 100 caracteres')
.regex(
/^(?=.* )[\p{L}\p{M}]+(?:[- ][\p{L}\p{M}]+)*$/u,
'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.',
),
email: z.email('Email inválido'),
phone: z
.string()
.trim()
.length(11, 'Telefone celular inválido')
.regex(/^\d{11}$/, 'Telefone celular inválido'),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
postalCode: z
.string()
.trim()
.regex(/^(?:\d{8})?$/, 'CEP inválido')
.optional(),
comment: z
.string()
.trim()
.max(1000, 'Comentário muito longo')
.refine((v) => !/<\/?[a-z][\s\S]*>/i.test(v), 'O comentário não pode conter HTML')
.optional(),
})
export type PetitionFormInput = z.infer<typeof petitionFormSchema>

View file

@ -1,29 +0,0 @@
export const STATES = [
'AC',
'AL',
'AM',
'AP',
'BA',
'CE',
'DF',
'ES',
'GO',
'MA',
'MG',
'MS',
'MT',
'PA',
'PB',
'PE',
'PI',
'PR',
'RJ',
'RN',
'RO',
'RR',
'RS',
'SC',
'SE',
'SP',
'TO',
]

View file

@ -209,10 +209,25 @@ export interface Petition {
};
[k: string]: unknown;
};
form: {
title?: string | null;
subtitle?: string | null;
consent: number | Consent;
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "consent".
*/
export interface Consent {
id: number;
text: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contact".
@ -256,16 +271,6 @@ export interface Contact {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "consent".
*/
export interface Consent {
id: number;
text: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "signature".
@ -510,6 +515,13 @@ export interface PetitionSelect<T extends boolean = true> {
subtitle?: T;
enabled?: T;
body?: T;
form?:
| T
| {
title?: T;
subtitle?: T;
consent?: T;
};
updatedAt?: T;
createdAt?: T;
_status?: T;