Skip to content

Checkout em SPA (React/Next.js) ​

O Appmax JS foi desenhado para páginas tradicionais: HTML renderizado no servidor, formulário nativo que navega, DOM estável do primeiro paint até a próxima navegação. Numa SPA nenhuma dessas premissas vale, e é aí que a integração costuma travar — quase sempre em silêncio.

Este guia mostra uma implementação completa em Next.js (App Router). Os conceitos valem para React, Vue, Svelte ou qualquer framework que monte o DOM dinamicamente.

As quatro regras ​

Se você guardar só isso desta página, já evita os quatro erros que consomem um dia de trabalho:

  1. O DOM antes do init. O init() faz querySelector uma única vez. Gatilho de IP e formulário de cartão precisam existir no DOM antes da chamada.
  2. O formulário fica sempre montado. Esconda com CSS, nunca com um condicional de render. Um {visible && <form>} quebra a tokenização sem erro nenhum.
  3. Os name dos campos são fixos. O SDK lê com new FormData(form), pelos name — e eles não são iguais aos valores do appmax-form-element.
  4. O preventDefault é seu. Existe uma janela entre a montagem do componente e o fim do init(), que é assíncrono. Um clique nessa janela faz o browser dar POST nativo e sair da sua aplicação.

1. Entregar o external_id ao browser ​

O externalId é o identificador da instalação do app naquela loja (veja external-id). Ele precisa chegar ao componente de checkout — normalmente do seu backend, junto com os dados do pedido.

ts
// app/checkout/page.tsx  (Server Component)
import CheckoutClient from './CheckoutClient';

export default async function CheckoutPage() {
  const { externalId } = await loadStoreInstallation();

  return <CheckoutClient externalId={externalId} />;
}

Use o external_id que a sua URL de validação respondeu

O valor que vale é o que foi devolvido no corpo da resposta ao health check — é esse que a Appmax persistiu. Se o seu handler devolveu um UUID e gravou outro no banco, o checkout vai entregar um id que não existe do nosso lado, e a tokenização falha com 404 Merchant not found. Veja Implementar a URL de validação.

2. Carregar e inicializar o SDK ​

Um hook concentra o carregamento do script e a inicialização. Dois pontos merecem atenção: a URL do script muda por ambiente, e o init só pode rodar depois de o DOM estar montado.

ts
// lib/useAppmax.ts
'use client';

import { useEffect, useRef } from 'react';

const SCRIPT_URL = process.env.NEXT_PUBLIC_APPMAX_SCRIPT_URL!;
// Produção: https://scripts.appmax.com.br/appmax.min.js
// Sandbox:  https://scripts.sandboxappmax.com.br/appmax.min.js

type Options = {
  externalId: string;
  onIp: (ip: string) => void;
  onToken: (token: string) => void;
};

export function useAppmax({ externalId, onIp, onToken }: Options) {
  // Guarda os callbacks em ref: o init captura o valor do momento da chamada,
  // e não queremos re-inicializar a cada render.
  const handlers = useRef({ onIp, onToken });
  handlers.current = { onIp, onToken };

  useEffect(() => {
    if (!externalId) return;

    const script = document.createElement('script');
    script.src = SCRIPT_URL;
    script.async = true;

    script.onload = () => {
      try {
        window.AppmaxScripts.init({
          externalId,
          onIp: ({ ip }) => handlers.current.onIp(ip),
          onTokenize: ({ token }) => handlers.current.onToken(token),
          onError: (err) => {
            console.error('[Appmax]', err.code, err.stage, err.status, err.details);
          },
        });
      } catch (err) {
        // init() lança exceção síncrona quando falta externalId no fluxo Apple Pay
        console.error('[Appmax] falha ao inicializar', err);
      }
    };

    document.head.appendChild(script);
    return () => { script.remove(); };
  }, [externalId]);
}

Por que try/catch em volta do init

No fluxo Apple Pay, um externalId ausente faz o init lançar de forma síncrona, dentro da própria chamada. Sem o try/catch, isso estoura no useEffect e pode derrubar a árvore inteira.

Sobre o StrictMode

O StrictMode do React monta efeitos duas vezes em desenvolvimento. Com o bundle atual isso é inofensivo: chamar init() de novo substitui o handler do formulário em vez de empilhar, então um clique gera uma tokenização. O efeito colateral é apenas refazer a coleta de IP.

3. O gatilho da coleta de IP ​

A coleta é obrigatória em toda integração e acontece quando o SDK encontra um <form data-appmax-customer> no DOM. Se você já tem um formulário de dados do comprador, use-o. Se o comprador vive no estado da aplicação — o caso comum em SPA — um form vazio e escondido serve de gatilho:

tsx
<form data-appmax-customer hidden />

O SDK injeta um <input type="hidden" name="ip"> dentro desse form. Ignore esse campo numa SPA: ele é escrito por DOM direto, fora do controle do React, e pode ser descartado num re-render. O valor confiável é o que chega no onIp.

4. O formulário de cartão ​

tsx
// app/checkout/CreditCardForm.tsx
'use client';

type Props = {
  visible: boolean;
  onSubmit: () => void;
};

export default function CreditCardForm({ visible, onSubmit }: Props) {
  return (
    <form
      data-appmax-checkout
      method="POST"
      // Regra 4: sem este preventDefault, um clique antes do init()
      // dispara o submit nativo e o browser sai da aplicação.
      onSubmit={(e) => { e.preventDefault(); onSubmit(); }}
      // Regra 2: sempre montado, escondido só por CSS.
      className={visible ? '' : 'hidden'}
    >
      <input required name="card-number"      placeholder="Número do cartão" inputMode="numeric" />
      <input required name="card-holder-name" placeholder="Nome impresso no cartão" />
      <input required name="exp-month"        placeholder="MM"   inputMode="numeric" />
      <input required name="exp-year"         placeholder="AAAA" inputMode="numeric" />
      <input required name="cvv"              placeholder="CVV"  inputMode="numeric" />
      <button type="submit">Pagar com cartão</button>
    </form>
  );
}

Os name são exatamente estes cinco — card-number, card-holder-name, exp-month, exp-year e cvv. Renomear qualquer um faz o campo chegar vazio na tokenização, e o erro que volta é um 422 listando os campos como field is required com a tela preenchida. A tabela completa está em Tokenização de pagamento.

O ano aceita 2 ou 4 dígitos: 31 e 2031 tokenizam igual.

Input controlado funciona

O FormData lê a propriedade value do elemento no DOM, e o React a mantém sincronizada — então um input controlado (com value e onChange) é lido normalmente. Use controlado onde você também precisa do valor no estado, como o nome do titular, que vai no payload do pagamento.

O que não funciona é value sem onChange: o React trata o input como somente leitura, o comprador não consegue digitar e o FormData lê o valor inicial.

5. Receber o token ​

O token chega no onTokenize, e é de uso único e vida curta — use imediatamente e não armazene.

tsx
// app/checkout/CheckoutClient.tsx
'use client';

import { useCallback, useRef, useState } from 'react';
import { useAppmax } from '@/lib/useAppmax';
import CreditCardForm from './CreditCardForm';

export default function CheckoutClient({ externalId }: { externalId: string }) {
  const [ip, setIp] = useState<string>('');
  const [processing, setProcessing] = useState(false);
  const inFlight = useRef(false);

  const onToken = useCallback(async (token: string) => {
    // Trava síncrona: protege contra cliques repetidos do comprador.
    // Um `useState` não serve aqui — a atualização é assíncrona.
    if (inFlight.current) return;
    inFlight.current = true;
    setProcessing(true);

    try {
      const res = await fetch('/api/checkout/card', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token, ip }),
      });
      if (!res.ok) throw new Error((await res.json()).message);
      window.location.href = '/checkout/success';
    } catch (err) {
      showError(err);
      inFlight.current = false;   // libera para o comprador tentar de novo
      setProcessing(false);
    }
  }, [ip]);

  useAppmax({ externalId, onIp: setIp, onToken });

  return (
    <>
      <form data-appmax-customer hidden />
      <CreditCardForm visible={!processing} onSubmit={() => {}} />
    </>
  );
}

A trava é por pedido, e síncrona

Com o bundle atual um clique gera uma tokenização, então a trava existe para proteger do comprador clicando duas vezes — não do SDK. Use uma ref ou variável, nunca estado de framework: a atualização do useState é assíncrona e chega tarde demais. E libere a trava quando o pagamento falhar, senão o comprador não consegue tentar de novo.

6. Efetivar o pagamento no backend ​

O token vai para o seu backend, que executa a sequência: criar o cliente, criar o pedido e efetivar o pagamento. As credenciais do merchant nunca chegam ao browser.

ts
// app/api/checkout/card/route.ts
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { token, ip } = await req.json();

  // 1. Criar o cliente — o ip é obrigatório
  const customer = await appmax('/v1/customers', {
    firstname: 'João', lastname: 'Silva',
    email: '[email protected]', telephone: '11999999999',
    ip,
  });

  // 2. Criar o pedido
  const order = await appmax('/v1/orders', {
    customer_id: customer.data.id,
    products: [{ sku: 'CAM-001', name: 'Camiseta', qty: 1, price: 129.9 }],
  });

  // 3. Efetivar o pagamento
  const payment = await appmax('/v1/payments/credit-card', {
    cart: { order_id: order.data.id },
    customer: { customer_id: customer.data.id },
    payment: {
      creditcard: {
        card_token: token,
        installments: 1,
      },
    },
  });

  return NextResponse.json(payment);
}

Propague o erro da Appmax

Um catch que devolve "erro ao processar pagamento" apaga justamente a informação de que você precisa. Repasse status e corpo da resposta da Appmax para o seu log — é o que diferencia um 422 de payload de um 404 de external_id.

Os contratos completos estão em Criar cliente, Criar pedido e Pagamento com cartão de crédito.

Checklist antes de ir para produção ​

  • [ ] A URL do script aponta para o ambiente certo (scripts.appmax.com.br em produção, scripts.sandboxappmax.com.br em sandbox).
  • [ ] O externalId é o da instalação naquele ambiente — reinstalar em produção gera um novo.
  • [ ] O formulário de cartão está sempre montado, escondido por CSS.
  • [ ] Os cinco name estão exatamente como a tabela manda.
  • [ ] Existe preventDefault próprio no onSubmit do formulário.
  • [ ] A trava contra duplo clique é síncrona (ref, não useState) e é liberada no erro.
  • [ ] O onError loga code, stage, status e details.
  • [ ] O IP usado no POST /v1/customers é o que chegou no onIp.

Para a tabela de sintoma → causa e o contrato completo dos callbacks, veja Appmax JS.