Pular para o conteúdo

Builder OS

Builder · OS
L05 · Uma tela, um form
~13 MIN DE LEITURA

Lição 5 de 13: Uma tela, um form

lição 5/13 do Módulo 3
AO FIM, VOCÊ VAI TER
  • 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:

  1. com 4 estados explícitos, cada um com UI diferente. Não use boolean (isLoading); use estado nomeado.
  2. handleSubmit é async: espera a response antes de mudar de estado. Sem isso, .
  3. Status HTTP diferentes geram mensagens diferentes: 400/409/500 não viram "erro genérico"; cada um tem mensagem específica.
  4. Máscara de CPF no pattern: validação básica HTML; o Zod do servidor é a fonte de verdade.
  5. disabled={state === 'submitting'} no botão previne double-submit.

Passo 2 — Cria no repo

Cole no Claude:

prompt · text
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.
RECURSO[RECURSO]mesmo recurso das lições anteriores (ex: recibo).

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:

  1. Submete vazio. O browser bloqueia por causa do required HTML. Boa: primeira camada de defesa funciona.
  2. 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 (o message do Zod da L03).
  3. 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

prompt · text
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.
  • handleSubmit async + botão disabled durante 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. brlToCentavos precisa lidar com separador de milhar brasileiro.

Você terminou quando

Cinco coisas:

  1. Página renderiza no browser local (http://localhost:3000/[recurso]s/novo ou equivalente)
  2. Submit com payload válido cria registro (você vê o id retornado)
  3. Submit com CPF inválido mostra mensagem específica do servidor
  4. Submit com CPF duplicado mostra mensagem 409
  5. Tudo commitado