← Blog

Como construir um checkout de uma página em React e Next.js que aguenta recusas e preços alterados

Um formulário de checkout é fácil. Recusas, 3-D Secure, preços que mudam antes do Pagar e pagamentos que nunca respondem não são. Um checkout de uma página em React e Next.js.

Preet Suthar · · 6 min de leitura

  • React
  • Next.js
  • E-commerce
  • Payments

A maioria dos tutoriais de checkout termina num formulário e num botão Pagar que chama a Stripe. Essa parte funciona de primeira. O que faz perder pedidos é tudo em volta: o cartão que é recusado, o banco que pede uma verificação 3-D Secure, o frete que muda com o CEP, a requisição que nunca volta e o cliente que pressiona Pagar uma segunda vez.

É assim que eu estruturo um checkout de uma página em React e Next.js, com os estados que importam e um trechinho de código para cada um. Funciona com shadcn/ui ou qualquer outro conjunto de componentes. A parte difícil é o fluxo, não o estilo.

Uma página, três etapas que se recolhem

Um checkout de uma página mantém tudo à vista: nenhum carregamento de página entre as etapas, e o botão Voltar do navegador não consegue jogar fora meio formulário. Mas um formulário longo é um paredão. A solução são três etapas na mesma página, contato, entrega e pagamento, com apenas uma aberta por vez.

Quando uma etapa termina, ela se recolhe numa linha de resumo, então um checkout concluído se lê como um recibo:

Contact   [email protected]                         Change
Ship to   12 Mission St, San Francisco, CA 94110   Change
Method    Standard · Arrives Oct 26 – 28 · Free    Change

O estado por trás disso é pequeno. Guarde a lista de etapas concluídas e derive a que está aberta:

const steps = ["contact", "delivery", "payment"] as const
type Step = (typeof steps)[number]

function openStep(done: Step[], editing: Step | null): Step {
  return editing ?? steps.find((step) => !done.includes(step)) ?? "payment"
}

Dois detalhes fazem a experiência parecer certa. Quando alguém pressiona Alterar, mova o foco para o título da etapa que abriu, ou quem usa teclado e leitor de tela fica parado num botão que não existe mais. E quando o cliente está logado, pule o contato e comece pela entrega com o endereço padrão dele.

Em telas largas, o resumo do pedido fica ao lado do formulário. Nos celulares, ele se recolhe numa barra "Mostrar resumo do pedido" acima do formulário que ainda mostra o total, porque o total é o número que as pessoas ficam conferindo.

Valide no blur, resuma ao pressionar Pagar

Mostre o erro de um campo quando o cliente sair dele e depois atualize-o ao vivo enquanto ele digita, para que suma no momento em que for corrigido. Nunca enquanto ele ainda está digitando pela primeira vez.

Não desabilite o Pagar até o formulário ser válido. Um botão desabilitado não consegue dizer a ninguém o que está faltando. Deixe a pessoa pressioná-lo e então coloque um único resumo no topo listando cada problema, ligue cada item ao seu campo e mova o foco para o resumo. O resumo de erros do GOV.UK Design System é uma boa referência para esse padrão.

Frete e impostos seguem o endereço

Frete e impostos dependem do endereço, então só podem carregar quando o endereço estiver completo. Espere uma pausa curta na digitação, cancele a requisição anterior quando o endereço mudar de novo e mostre "Calculando…" no resumo em vez de um número desatualizado.

useEffect(() => {
  if (!isComplete(address)) return
  const controller = new AbortController()
  const pause = setTimeout(async () => {
    setQuote({ status: "loading" })
    try {
      const [rates, tax] = await Promise.all([
        getRates(address, controller.signal),
        estimateTax(address, controller.signal),
      ])
      setQuote({ status: "ready", rates, tax })
    } catch {
      if (!controller.signal.aborted) setQuote({ status: "error" })
    }
  }, 300)
  return () => {
    clearTimeout(pause)
    controller.abort()
  }
}, [address])

Quando chegarem novas opções de frete, mantenha a escolhida se ela ainda estiver disponível. Se não estiver, escolha a mais barata e avise: "Expresso não está disponível para este endereço." Trocar o método em silêncio é como as pessoas acabam pagando por um frete que não escolheram.

Pagamento expresso vem primeiro

Apple Pay, Google Pay, Shop Pay e PayPal substituem o formulário inteiro, então eles pertencem ao topo, acima do contato, com um divisor "Ou pague com cartão" logo abaixo. Colocá-los ao lado do botão Pagar significa que o cliente já digitou o endereço quando vê o caminho mais rápido. Renderize só as carteiras que o navegador realmente consegue usar. O Express Checkout Element da Stripe e SDKs parecidos descobrem isso por você.

Pagar é uma máquina de estados, não um booleano

isSubmitting não consegue descrever o que acontece depois do Pagar. Dê um tipo à resposta do servidor:

type PlaceOrderResult =
  | { status: "succeeded"; orderId: string }
  | { status: "declined"; code: string }
  | { status: "requires_action"; clientSecret: string }
  | { status: "changed"; changes: PriceChange[] }

Depois trate cada caso de propósito.

Pagar não pode rodar duas vezes. Um clique duplo dispara dois handlers antes de o React renderizar de novo, então uma checagem com disabled={pending} não basta. Proteja com uma ref e trave os botões Alterar enquanto o pedido está sendo feito, para que o carrinho não mude por baixo do pagamento.

const placing = useRef(false)

async function pay() {
  if (placing.current) return
  placing.current = true
  try {
    handleResult(await placeOrder(draft, attemptKey))
  } finally {
    placing.current = false
  }
}

Toda tentativa recebe uma chave de idempotência. Crie-a com crypto.randomUUID() quando Pagar for pressionado e envie-a junto com a requisição de pagamento. A Stripe e a maioria das APIs de pagamento aceitam uma, e uma requisição repetida com a mesma chave não consegue cobrar duas vezes.

Sair no meio do pagamento pede confirmação antes. Adicione um listener de beforeunload enquanto um pagamento está em andamento e remova-o quando ele terminar.

Recusas precisam de palavras, não de "Algo deu errado"

Uma recusa é a falha mais comum no checkout e a que tem o pior texto. Mapeie os códigos que você recebe para frases que digam à pessoa o que fazer, mova o foco para a mensagem e mantenha os dados do cartão preenchidos:

const declineCopy: Record<string, string> = {
  insufficient_funds: "Your card has insufficient funds. Try another card.",
  expired_card: "This card has expired. Check the date or use another card.",
  incorrect_cvc: "The security code doesn't match. Check it and try again.",
}

const fallbackDecline =
  "Your bank declined this payment. Try another card or contact your bank."

requires_action não é um erro. Significa que o banco quer 3-D Secure, o que é rotina na Europa por causa da autenticação forte do cliente. Mantenha o estado de processamento, abra o desafio do banco num diálogo chamado "Confirme com o seu banco" e continue quando ele for resolvido.

Quando o preço mudou desde o carrinho

Preços, estoque e descontos podem mudar entre o carrinho e o Pagar. Envie com o pedido o total que o cliente viu, recalcule o preço no servidor e recuse a cobrança se forem diferentes:

"use server"

export async function placeOrder(draft: Draft, key: string) {
  const cart = await repriceCart(draft.lines)
  if (cart.total !== draft.expectedTotal) {
    return { status: "changed", changes: diffLines(draft, cart) } as const
  }
  return chargeAndCreateOrder(cart, draft, key)
}

No cliente, atualize o resumo, risque o total antigo e liste cada mudança em palavras simples, como "Ring vase agora custa $74, antes custava $68." O cliente pressiona Pagar de novo com o novo valor. Um item que esgotou durante o checkout funciona do mesmo jeito: tire-o do total, sinalize-o e deixe a pessoa removê-lo ou continuar sem ele.

Quando o pagamento nunca responde

Às vezes a requisição trava. A pior coisa que você pode mostrar depois de um timeout é "Tente novamente", porque a primeira cobrança pode ter passado. Depois de uma espera razoável, diga "Verificando o status do seu pagamento…" e consulte um endpoint de status com a mesma chave de idempotência até saber se existe um pedido. Só ofereça uma nova tentativa quando o servidor disser que a anterior falhou.

Um checklist

  • As etapas se recolhem em linhas de resumo, e Alterar move o foco para a etapa reaberta.
  • Erros no blur, um resumo com links ao pressionar Pagar, nenhum botão Pagar desabilitado.
  • Frete e impostos esperam o endereço completo, cancelam requisições antigas e nunca mostram um total desatualizado.
  • Carteiras expressas no topo, só as que o navegador suporta.
  • Uma proteção com ref, uma chave de idempotência e um aviso de beforeunload em volta do Pagar.
  • Texto específico para cada código de recusa, com o formulário ainda preenchido.
  • 3-D Secure como uma etapa, não como um erro.
  • Preços alterados e itens esgotados interrompem a cobrança e se explicam.
  • Um timeout consulta o status em vez de oferecer pagar de novo.

Construindo com shadcn/ui

O shadcn/ui dá a você os inputs, radio groups e collapsibles para tudo isso. O fluxo acima é a parte que você mesmo escreve, e é a maior parte do trabalho.

Eu construí isso uma vez como o bloco Checkout do HextaUI Pro, feito com os componentes gratuitos do HextaUI. Ele recebe o carrinho e três callbacks, onGetRates, onEstimateTax e onPlaceOrder, e trata os quatro resultados acima. Se onPlaceOrder não responder em 20 segundos, ele consulta onCheckStatus com a mesma chave de idempotência em vez de oferecer pagar de novo. Ele vem com exemplos para Stripe PaymentIntents com server actions, repasse para o checkout da Shopify e Medusa. Ele usa o bloco Payment para carteiras, cartões, recusas e 3-D Secure, e o Address Form para a entrega. Todo bloco tem um preview ao vivo que você pode explorar antes de decidir qualquer coisa.

Se a sua loja roda na Shopify, use o checkout hospedado da Shopify em vez de reconstruí-lo. Um checkout próprio faz sentido quando você mesmo recebe o pagamento.

FAQ

Um checkout de uma página é melhor que um de várias etapas? Para a maioria das lojas, uma única página com etapas que se recolhem oferece o melhor dos dois: tudo fica à vista, e a pessoa só encara uma seção por vez.

Devo desabilitar o botão Pagar até o formulário ser válido? Não. Deixe a pessoa pressioná-lo e mostre um resumo do que está faltando, com links para cada campo.

Como eu evito cobranças duplicadas? Proteja o handler com uma ref, envie uma chave de idempotência em toda tentativa e consulte o status de um pagamento que estourou o tempo em vez de deixar a pessoa pagar de novo.

Onde Apple Pay e Google Pay devem ficar? No topo do checkout, antes dos dados de contato, porque eles substituem o resto do formulário.