Como incluir o SDK
O SDK é exposto globalmente como
window.CeletusUpsell — não há pacote
npm (v1). Carregue o script uma vez antes do seu componente:
Next.js (App Router ou Pages)
// app/layout.tsx ou pages/_document.tsx
import Script from "next/script";
<Script
src="https://pay.celetus.com/assets/js/celetus.upsell.sdk.js"
strategy="afterInteractive"
/>
CRA / Vite
Adicione no public/index.html (CRA) ou
index.html (Vite), antes do
</body>:
<script src="https://pay.celetus.com/assets/js/celetus.upsell.sdk.js"></script>
Pegue a key no painel
No painel Celetus, em Funil de vendas, clique no
ícone </> da etapa e copie o
valor da key. Ela fica hardcoded no
seu código (ou numa variável de ambiente), igual à
key usada hoje em
Upsell.Widget.show({ key }).
O sfid vem automaticamente da query
string da página (redirect pós-compra) — não precisa passar.
Tipagem (TypeScript)
Declare o tipo global para o autocomplete funcionar:
// src/types/celetus.d.ts
declare global {
interface Window {
CeletusUpsell: {
init(config: { key: string; sfid?: string }): CeletusUpsellInstance;
toMoneyBRL(value: number): string;
};
}
}
interface CeletusUpsellInstance {
step: StepData | null;
charge: ChargeData | null;
loadStep(opts?: { force?: boolean }): Promise<StepData>;
accept(opts?: { force?: boolean }): Promise<ChargeData>;
next(opts?: {
direction?: "positive" | "negative";
redirectUrl?: string;
noRedirect?: boolean;
}): string;
decline(opts?: { redirectUrl?: string; noRedirect?: boolean }): string;
pollStatus(opts?: { intervalMs?: number; timeoutMs?: number }): void;
stopPolling(): void;
destroy(): void;
clearStepCache(): void;
on(event: string, fn: (payload: any) => void): () => void;
off(event: string, fn: (payload: any) => void): void;
}
export {};
O hook useCeletusUpsell()
// src/hooks/useCeletusUpsell.ts
import { useEffect, useRef, useState } from "react";
type Status =
| "idle"
| "loading-step"
| "ready"
| "processing"
| "awaiting-pix"
| "paid"
| "failed"
| "error";
export function useCeletusUpsell(config: { key: string }) {
const instanceRef = useRef<any>(null);
const [status, setStatus] = useState<Status>("idle");
const [step, setStep] = useState<any>(null);
const [pix, setPix] = useState<{ pixQrCode?: string; pixQrUrl?: string } | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (typeof window === "undefined" || !window.CeletusUpsell) return;
const upsell = window.CeletusUpsell.init({ key: config.key });
instanceRef.current = upsell;
const offs: Array<() => void> = [];
offs.push(upsell.on("qrReady", (p: any) => {
setPix({ pixQrCode: p.pixQrCode, pixQrUrl: p.pixQrUrl });
setStatus("awaiting-pix");
}));
offs.push(upsell.on("paid", () => setStatus("paid")));
offs.push(upsell.on("failed", (p: any) => { setStatus("failed"); setError(p.reason); }));
offs.push(upsell.on("error", (err: Error) => { setError(err.message); }));
setStatus("loading-step");
upsell
.loadStep()
.then((s: any) => { setStep(s); setStatus("ready"); })
.catch((err: Error) => { setError(err.message); setStatus("error"); });
return () => {
offs.forEach((off) => off());
upsell.destroy();
};
}, []);
return {
status,
step,
pix,
error,
accept: () => {
setStatus("processing");
return instanceRef.current?.accept();
},
// Avança para a próxima etapa do funil após pagamento confirmado.
// Chame dentro do handler de `paid` (ou quando quiser, pra dar UX de
// sucesso antes do redirect).
next: (opts?: Parameters<CeletusUpsellInstance["next"]>[0]) =>
instanceRef.current?.next(opts),
decline: () => instanceRef.current?.decline(),
};
}
Usando no componente
// src/pages/upsell.tsx
import { useCeletusUpsell } from "../hooks/useCeletusUpsell";
export default function UpsellPage() {
const { status, step, pix, error, accept, decline, next } = useCeletusUpsell({
key: "SUA_KEY_AQUI", // ← copiada do painel Celetus
});
// Avança para a próxima etapa do funil após mostrar sucesso por 1.5s.
useEffect(() => {
if (status === "paid") {
const t = setTimeout(() => next(), 1500);
return () => clearTimeout(t);
}
}, [status, next]);
if (status === "loading-step" || status === "idle") return <p>Carregando…</p>;
if (status === "error") return <p>Erro: {error}</p>;
if (status === "paid") return <p>✅ Pagamento confirmado!</p>;
if (status === "awaiting-pix" && pix) {
const isImg = pix.pixQrUrl?.startsWith("http");
return (
<div>
<h1>Pague com PIX</h1>
{isImg ? <img src={pix.pixQrUrl} alt="QR" /> : <code>{pix.pixQrCode}</code>}
<button onClick={() => navigator.clipboard.writeText(pix.pixQrCode || "")}>
Copiar chave PIX
</button>
</div>
);
}
return (
<div>
<h1>{step.productName}</h1>
<p>{step.valueFormatted}</p>
<button onClick={accept} disabled={status === "processing"}>
{step.positiveButtonLabel}
</button>
<button onClick={decline}>{step.negativeButtonLabel}</button>
</div>
);
}
Exemplo executável
Veja um componente React rodando 100% standalone (Babel + React via CDN, sem build): abrir exemplo.html.
Encadeamento entre etapas do funil
O SDK sabe para onde avançar depois de cada etapa — as URLs
(positiva e negativa) vêm do painel Celetus e estão disponíveis
em step.positivePageUrl /
step.negativePageUrl após o
loadStep().
Dentro do componente, chame next()
no handler de paid (com delay, se quiser mostrar uma
tela de sucesso antes):
useEffect(() => {
if (status === "paid") {
const t = setTimeout(() => next(), 1500);
return () => clearTimeout(t);
}
}, [status, next]);
Se quiser renderizar um <Link>
do Next.js em vez de redirecionar imperativamente, use
next({ noRedirect: true }) pra
obter a URL de destino:
<Link href={next({ noRedirect: true })}>Continuar</Link>
⚠️ Link de uso único (importante para testes)
localStorage por 1 minuto para
sobreviver a reloads / hot reloads do dev server dentro dessa
janela.
- Ao desenvolver no Vite / Next.js, HMR não invalida o cache — você pode trocar o JSX à vontade que o mesmo link continua válido por até 1 minuto desde o primeiro load.
-
Se o hook cair em
status === "error"com mensagem "Link inválido ou já consumido", gere uma nova compra de teste para obter umsfidfresco. -
Exponha
clearStepCache()no hook se precisar de um botão "tentar novamente" — lembrando que isso não revive um link já consumido no backend.
Next.js — cuidados com SSR
-
O hook checa
typeof windowantes de usarCeletusUpsell, então é seguro em SSR — apenas não renderiza nada até hidratar. -
Use
strategy="afterInteractive"no<Script>pra garantir que o SDK carrega antes do hook rodar no cliente. -
Se preferir, marque a página com
"use client"(App Router) ou importe dinamicamente comdynamic(() => import(...), { ssr: false }).