Projeto final: checkout completo com validação ponta a ponta
6 min de leitura
Hora de unir tudo. Você vai construir um checkout de 3 steps com validação compartilhada client/server, persistência local, integração com API mockada, e teste E2E mínimo. É o ciclo completo: schema único, form wizard, submit real, e validação de ponta a ponta.
Esse projeto não segue o esqueleto de 3 camadas dos
outros nós. É um brief de projeto, no estilo de
projects/<slug>.mdx do aprenda-community. Lê até
o fim antes de começar.
O que você vai construir
Um app de checkout de e-commerce com 3 steps:
dados pessoais (nome, email, telefone), endereço
de entrega (CEP, rua, cidade, estado), e pagamento
(número de cartão, validade, CVV - mockado, sem
gateway real). Cada step valida antes de avançar.
O user pode voltar e editar steps anteriores. O form
persiste em localStorage se a aba fechar. O submit
final vai pra uma API mockada que valida com o mesmo
schema Zod.
A escolha do "domínio" (e-commerce vs. cadastro
longo vs. onboarding) é sua - o esqueleto dado é o
caminho feliz, desvie quando precisar e anote
as decisões no editorial-decisions.md da trilha.
Objetivo
- Consolidar React Hook Form + Zod + ZodResolver num app real de produção.
- Praticar wizard de 3 steps com validação por step e submit final.
- Ver o mesmo schema Zod rodando no client
(via
zodResolver) e no server (viasafeParseno handler da API). - Persistir o form em
localStoragee recuperar no reload. - Subir a API mockada local e ver o fluxo ponta a ponta funcionando.
Requisitos (mínimo)
Setup inicial:
-
pnpm create vite@latest meu-checkout -- --template react-ts(ou Next.js se preferir, mas Vite é mais leve pra este projeto). - Instalar:
pnpm add react-hook-form @hookform/resolvers zod. - Instalar (server mock):
pnpm add -D json-serverou similar (ou um Express mínimo emserver.ts). - Estrutura:
src/schemas/checkout.ts(schema compartilhado),src/components/Wizard.tsx(pai),src/components/Step1DadosPessoais.tsx,src/components/Step2Endereco.tsx,src/components/Step3Pagamento.tsx,src/components/Stepper.tsx,src/api/checkout.ts(fetch + setError),server/index.ts(Express/Hono comsafeParse).
Schema (compartilhado client + server):
-
src/schemas/checkout.tscom schema Zod completo (nome, email, telefone, cep, rua, cidade, estado, numeroCartao, validade, cvv). - Mensagens de erro em PT-BR direto no schema.
- Refine que checa
senha === confirmaSenha(ou outro campo duplicado relevante, ex:estadopertence à lista de UFs). - Exporta
CheckoutData = z.infer<typeof checkoutSchema>.
Wizard (RHF + FormProvider):
-
<Wizard />(pai) comuseForm<CheckoutData>,FormProvider, e statecurrentStep(tipo union de 3 strings). - 3 sub-componentes (Step1, Step2, Step3) usando
useFormContext. -
handleNextvalida commethods.trigger(stepFields[currentStep])antes de avançar. -
handleBackvolta pro step anterior. - Stepper visual com
aria-current="step"no step atual. -
<fieldset disabled={isSubmitting}>no form inteiro durante o submit. - Botão "Finalizar" só no step 3; chama
handleSubmit(onSubmit).
Persistência local:
-
useEffectcommethods.watchsalva o form emlocalStoragea cada mudança. -
defaultValueslê dolocalStoragese existir (com fallback seguro pra SSR). - Limpa
localStorageno submit bem-sucedido.
API mockada (server):
-
POST /api/checkoutque importacheckoutSchemae rodasafeParse(req.body). - Em sucesso:
201com{ id, message: "Pedido criado" }(não persiste em banco real - é mock). - Em erro de validação:
400com{ errors: result.error.flatten() }. - CORS habilitado (ou Vite proxy pra
localhost:3001).
Integração client:
-
onSubmitfazfetch("/api/checkout", { method: "POST", ... }). - Em
!response.ok, lêerrorBody.errors.fieldErrorse mapeia prasetError(field, { type: "server", message }). - Em
response.status === 401ou>= 500, usasetError("root", ...)pra erro global. - Em sucesso, mostra tela de confirmação (nome + ID do pedido).
UX:
- Loading state no botão ("Enviando..." com
isSubmitting). - Mensagem de erro global em destaque se
errors.root. - Erro por campo com
aria-invalidearia-describedby. - Indicador de progresso (Step 1 de 3, etc).
Testes:
- 1 teste E2E mínimo com Playwright: preencher step 1 → avançar → preencher step 2 → avançar → preencher step 3 → submeter → ver confirmação. (Opcional, mas conta como stretch goal se você pular.)
Desafios extras (stretch goals)
Se você terminou o mínimo e quer ir além:
- Recuperação de step via URL: salvar
currentStepem?step=2e restaurar no mount (useuseSearchParamsse Next, ouwindow.locationem SPA). - Async validation no schema: checar se
CEP existe via ViaCEP no schema (
superRefineasync), comisValidatingno RHF. - Máscaras de input: CPF, CEP, cartão de
crédito, data de validade. Use
@react-input/maskou similar. - Validação server-only (email já existe):
refinar o schema no server com
superRefineasync que consulta um "banco" mock (em memória). - Optimistic UI no submit: mostrar "Processando..." com progress bar enquanto o server responde.
- Resetar form depois de submit:
methods.reset()+localStorage.removeItem- redirecionar pra
/.
- redirecionar pra
- Validação cross-step: usar
getValuespra validar step 3 em função de step 1 (ex: email de pagamento precisa bater com email pessoal). - i18n de mensagens de erro: usar
z.setErrorMappra trocar mensagens baseado em idioma do user. - Animações entre steps: Framer Motion
pra fade/slide entre steps, com
AnimatePresence. - Dark mode no form: suportar tema dark
com
next-themes(Next) ou Context API (Vite). - Sentry pra capturar erros de submit:
1 linha no
onErrorda mutation. - Webhook de "pedido criado" (mock): no sucesso do server, fazer uma chamada assíncrona pra uma URL mockada (não bloqueia a response).
Dicas
Por onde começar:
- Schema primeiro:
src/schemas/checkout.tscom todos os campos. Sem schema, não tem o que tipar. Use.min,.email,.regexdireto nas mensagens em PT-BR. - Wizard skeleton:
<Wizard />comcurrentStep(useState),FormProvider, e os 3 sub-componentes comuseFormContext. Sem validação por enquanto - só navegação. - Validação por step:
methods.trigger(camposDoStep)nohandleNext. Teste indo e voltando entre steps. - Submit final:
handleSubmit(onSubmit)chamafetch. Mapeie erros doflatten()prasetError. - API mockada: Express ou Hono num
server/index.ts, valida com o mesmo schema. Rode em paralelo (Vite proxy ou porta separada). - Persistência:
useEffect+methods.watchlocalStorage. Limpa no submit.
Armadilhas comuns:
- Esquecer o
key={field.id}em field arrays. Se o form tiver lista de telefones, useid, nãoindex. - Re-validar tudo no
handleNext. Usemethods.trigger(stepFields[currentStep])pra validar só o step atual. Validar o form inteiro mostra erro de campos que o user nem tocou. - Tratar erros remotos como
alert(). UsesetError(field, ...)pra erro de campo, esetError("root", ...)pra erro global. O user precisa ver o erro perto do campo. - Esquecer o
<fieldset disabled>. Sem ele, o user pode clicar em outros botões do form durante o submit.<fieldset disabled={isSubmitting}>resolve. - Validar no client só. A validação do client serve pra UX; o server sempre valida de novo. Confiar no client é bug de segurança.
- Não desabilitar o form durante submit. O
isSubmittingdeve desabilitar o form inteiro (fieldset) e o botão de submit. - Esquecer de tratar 401/500. O
flatten()cobre erro de validação (400). Pra auth (401) ou erro de servidor (5xx), usesetError("root", ...). - CORS mal configurado no server mock. Sem CORS ou proxy, o fetch do client (porta 5173) vai bater no server (porta 3001) e o browser bloqueia. Use Vite proxy ou habilite CORS no Express.
- Carregar
localStorageno SSR (Next). Em Next,localStoragenão existe no servidor. UseuseEffectpra carregar, outypeof window !== "undefined".
Como validar que terminou:
-
pnpm devroda sem warning de hidratação (se Next) nem de TS. - Preencher step 1 com dados inválidos (nome vazio, email mal formatado) → clica "Continuar" → erros aparecem nos campos certos.
- Preencher válido → avança pro step 2.
- Voltar pro step 1 → dados preenchidos continuam lá.
- Recarregar a página no step 2 → step 1 e step 2 mantêm os dados (persistência).
- Preencher step 3 → submete → tela de confirmação.
- No DevTools, ver o POST com payload JSON
bate com
CheckoutData. - Ver o server mock respondendo 201 com
{ id, message }. - Tentar submeter com
numeroCartaoinválido (14 dígitos em vez de 16) → erro do server aparece no campo certo viasetError. - Desligar o server mock e tentar submeter → erro global aparece ("Erro no servidor").
Leituras que ajudam durante o projeto:
- React Hook Form - FormProvider - o contexto que conecta os steps.
- Zod - Error Handling - o
flatten()que o server devolve pro client. - Hono - Getting Started - se você escolher Hono pro server mock.
O projeto final é onde a trilha vira "sua". As escolhas de domínio (e-commerce vs. cadastro vs. onboarding), de framework de server (Express vs. Hono vs. Next route handler), de persistência (
localStoragevs. IndexedDB vs. cookie) são todas suas. O esqueleto dado é o caminho feliz, desvie quando precisar, e anote as decisões noeditorial-decisions.mdda trilha.