Lição 5 de 13: Uma tela, um form
- Página da feature com Server Component + Client Component do formulário
- Formulário integrado à API: submit, loading, success, erro
- Máscaras de CPF/CNPJ e BRL nos inputs apropriados
- Commit
feat(ui): [recurso] form connected to api
A primeira tela do produto
Backend pronto: tabela na L02, validação na L03, API route inserindo na L04. Esta lição constrói a primeira tela que aciona tudo: um formulário com 4-6 campos que o usuário preenche e submete.
O formulário tem quatro estados visíveis e distintos: esperando preenchimento (idle), enviando (submitting), deu certo (success) e deu errado (error com mensagem específica). E dois detalhes brasileiros que precisam estar certos: de CPF/CNPJ e valor em BRL. Você cola o componente no repo, roda npm run dev e exercita os estados quebrando de propósito.
As mensagens em pt-BR mudam conforme o tipo de erro (CPF inválido, CPF duplicado, erro genérico). É isso que dá acabamento de produto à tela.
Passo 1 — Entenda o componente
Cria app/[recurso]s/novo/page.tsx no seu repo, cola o componente abaixo, ajusta os 4-6 campos pro seu caso e roda npm run dev.
'use client'
import { useState } from 'react'
type FormState = 'idle' | 'submitting' | 'success' | 'error'
export function ReciboForm() {
const [state, setState] = useState<FormState>('idle')
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [createdId, setCreatedId] = useState<number | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setState('submitting')
setErrorMsg(null)
const formData = new FormData(e.currentTarget)
const payload = {
cliente_cpf: cleanDocument(formData.get('cliente_cpf') as string),
valor_centavos: brlToCentavos(formData.get('valor') as string),
// ...outros campos
}
const res = await fetch('/api/recibos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (res.status === 201) {
const data = await res.json()
setCreatedId(data.id)
setState('success')
} else if (res.status === 400) {
const data = await res.json()
setErrorMsg(data.message || 'Dados inválidos')
setState('error')
} else if (res.status === 409) {
setErrorMsg('Já existe um recibo com esse CPF.')
setState('error')
} else {
setErrorMsg('Erro ao processar. Tente novamente.')
setState('error')
}
}
if (state === 'success') {
return <p>Recibo #{createdId} criado.</p>
}
return (
<form onSubmit={handleSubmit}>
<label>CPF do cliente
<input name="cliente_cpf" required pattern="\d{3}\.?\d{3}\.?\d{3}-?\d{2}" />
</label>
<label>Valor (R$)
<input name="valor" required type="text" placeholder="0,00" />
</label>
{errorMsg && <p role="alert">{errorMsg}</p>}
<button type="submit" disabled={state === 'submitting'}>
{state === 'submitting' ? 'Enviando...' : 'Criar recibo'}
</button>
</form>
)
}Pontos a observar:
- com 4 estados explícitos, cada um com UI diferente. Não use boolean (
isLoading); use estado nomeado. handleSubmitéasync: espera a response antes de mudar de estado. Sem isso, .- Status HTTP diferentes geram mensagens diferentes: 400/409/500 não viram "erro genérico"; cada um tem mensagem específica.
- Máscara de CPF no
pattern: validação básica HTML; o Zod do servidor é a fonte de verdade. disabled={state === 'submitting'}no botão previne double-submit.
Passo 2 — Cria no repo
Cole no Claude:
Cria uma página em `app/s/novo/page.tsx` (Next.js App Router):
1. `page.tsx` é Server Component (default no App Router) — só renderiza um título e importa o Client Component.
2. `new--form.tsx` (no mesmo diretório) é Client Component (`'use client'` no topo) com o formulário completo: 4 estados (idle/submitting/success/error), submit pra `/api/s`, helpers de máscara em `lib/format/`.
3. Use o Zod schema da L03 pra inferir o tipo do payload — não duplique definição de campos.
Mostre o diff antes de aplicar.Aprove. Roda o dev server. Acessa a rota no browser, preenche com CPF válido e valor (ex: R$ 100,00). Você deve ver:
- Estado "Enviando..." enquanto a request roda
- Após 201: "Recibo #X criado."
- Se CPF inválido: erro do servidor aparece no
role="alert"
Passo 3 — Quebra o form de propósito
Antes do commit, quebra. Três cenários:
- Submete vazio. O browser bloqueia por causa do
requiredHTML. Boa: primeira camada de defesa funciona. - Submete CPF com formato OK mas inválido (ex:
000.000.000-00). O browser deixa passar; o servidor rejeita com 400; sua UI mostra a mensagem. Confirma que aparece em pt-BR (omessagedo Zod da L03). - Submete o mesmo CPF duas vezes seguidas. O servidor rejeita o segundo com 409. UI mostra "Já existe um recibo com esse CPF.", a mensagem específica do 409, e não o fallback "Erro ao processar."
Se algum dos 3 quebra de jeito feio (página em branco, console vermelho, mensagem genérica), corrige antes de fazer commit.
Passo 4 — Faça o commit
Faça commit da página + formulário criados. Stage os arquivos da página da feature, com mensagem `feat(ui): [recurso] form connected to api`. Mostra os comandos antes de executar.Build Diary — o CEAP escolheu não ter formulários
O CEAP não tem nenhum formulário. É ferramenta de exploração de dados: o usuário consulta, não cria. Mas o DECISIONS.md ADR-001 registra essa decisão como escolha arquitetural consciente, e não como um "ainda não fizemos":
ADR-001: Static JSON Data Architecture
Context: Data changes infrequently (monthly batch from Câmara dos Deputados API). Zero need for write operations from end users.
Decision: Static JSON files generated at build time. No backend database for end-user data. No forms.
Consequences:
- Pro: zero infrastructure cost; site can be Cloudflare Pages with no compute.
- Pro: data is auditable — anyone can download the JSON and reproduce.
- Con: any data update requires a rebuild (~2min).
- Con: cannot accept user submissions.
A lição vale pro seu produto mesmo que ele tenha formulário: a decisão de ter (ou não ter) formulário tem consequências mensuráveis. Custo de infra, complexidade de auth, vetor de ataque, requisito de moderação. O CEAP fez essa pergunta e respondeu "não" pra fatia atual. Você está respondendo "sim" porque o wedge da L01 exige criação de registro pelo usuário. Nos dois casos, a escolha vem de uma pergunta respondida, e não de um default herdado.
Takeaways
- Estados explícitos (
'idle' | 'submitting' | 'success' | 'error') em vez de boolean (isLoading). Cada um tem UI diferente. handleSubmitasync + botãodisableddurante submit evita race condition de double-click.- Status HTTP diferentes (400/409/500) geram mensagens diferentes em pt-BR. "Erro ao processar" é só fallback final.
- Máscaras de CPF e BRL: o usuário vê formatado, o sistema guarda número cru.
brlToCentavosprecisa lidar com separador de milhar brasileiro.
Você terminou quando
Cinco coisas:
- Página renderiza no browser local (
http://localhost:3000/[recurso]s/novoou equivalente) - Submit com payload válido cria registro (você vê o id retornado)
- Submit com CPF inválido mostra mensagem específica do servidor
- Submit com CPF duplicado mostra mensagem 409
- Tudo commitado