feat: initial commit — asaas-checkout template white-label

Template genérico de checkout com ASAAS, parametrizado via env vars.
Inclui fluxo completo: checkout → pedido → polling → webhook → admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 06:40:41 +02:00
commit 038ce3f556
103 changed files with 20709 additions and 0 deletions

424
lib/actions.ts Normal file
View File

@@ -0,0 +1,424 @@
"use server"
import { createServiceClient } from "@/lib/supabase"
import { buscarOuCriarClienteAsaas, criarPagamentoAsaas, type MetodoPagamento } from "@/lib/asaas"
import { revalidatePath } from "next/cache"
// =============================================
// PRODUTOS
// =============================================
export async function getProdutos() {
const supabase = createServiceClient()
const { data, error } = await supabase
.from("produtos")
.select("*")
.eq("ativo", true)
.order("preco_centavos", { ascending: true })
if (error) throw new Error(error.message)
return data
}
export async function getProdutoById(id: string) {
const supabase = createServiceClient()
const { data, error } = await supabase
.from("produtos")
.select("*")
.eq("id", id)
.single()
if (error) throw new Error(error.message)
return data
}
export async function createProduto(data: {
nome: string
descricao?: string
tipo: "PF" | "PJ" | "SSL" | "NFe"
validade: string
midia?: string
preco_centavos: number
imagem_url?: string
}) {
const supabase = createServiceClient()
const { error } = await supabase.from("produtos").insert(data)
if (error) throw new Error(error.message)
revalidatePath("/admin")
revalidatePath("/produtos")
return { success: true }
}
export async function updateProduto(
id: string,
data: Partial<{
nome: string
descricao: string
tipo: "PF" | "PJ" | "SSL" | "NFe"
validade: string
midia: string
preco_centavos: number
ativo: boolean
imagem_url: string
}>
) {
const supabase = createServiceClient()
const { error } = await supabase.from("produtos").update(data).eq("id", id)
if (error) throw new Error(error.message)
revalidatePath("/admin")
revalidatePath("/produtos")
return { success: true }
}
export async function deleteProduto(id: string) {
const supabase = createServiceClient()
const { error } = await supabase.from("produtos").update({ ativo: false }).eq("id", id)
if (error) throw new Error(error.message)
revalidatePath("/admin")
return { success: true }
}
// =============================================
// CLIENTES
// =============================================
export async function getClientes(options: { limit?: number; search?: string } = {}) {
const supabase = createServiceClient()
let query = supabase.from("clientes").select("*").order("created_at", { ascending: false })
if (options.search) {
query = query.or(
`nome.ilike.%${options.search}%,email.ilike.%${options.search}%,cpf_cnpj.ilike.%${options.search}%`
)
}
if (options.limit) query = query.limit(options.limit)
const { data, error } = await query
if (error) throw new Error(error.message)
return data
}
export async function createCliente(data: {
nome: string
email: string
telefone?: string
cpf_cnpj?: string
}) {
const supabase = createServiceClient()
const { data: cliente, error } = await supabase
.from("clientes")
.insert(data)
.select()
.single()
if (error) throw new Error(error.message)
revalidatePath("/admin")
return cliente
}
// =============================================
// CHECKOUT — cria cliente no ASAAS + pedido
// =============================================
export async function criarCheckout(input: {
produtoId: string
cliente: {
nome: string
email: string
cpfCnpj: string
telefone?: string
}
metodo: MetodoPagamento
cartao?: {
holderName: string
number: string
expiryMonth: string
expiryYear: string
ccv: string
postalCode: string
addressNumber: string
}
}) {
const supabase = createServiceClient()
// 1. Busca produto
const { data: produto, error: produtoError } = await supabase
.from("produtos")
.select("*")
.eq("id", input.produtoId)
.eq("ativo", true)
.single()
if (produtoError || !produto) throw new Error("Produto não encontrado")
// 2. Busca ou cria cliente no banco (por email ou CPF/CNPJ)
let cliente = await supabase
.from("clientes")
.select("*")
.or(`email.eq.${input.cliente.email},cpf_cnpj.eq.${input.cliente.cpfCnpj}`)
.limit(1)
.single()
.then(({ data }) => data)
if (!cliente) {
const { data: novoCliente, error } = await supabase
.from("clientes")
.upsert({
nome: input.cliente.nome,
email: input.cliente.email,
telefone: input.cliente.telefone,
cpf_cnpj: input.cliente.cpfCnpj,
}, { onConflict: "cpf_cnpj" })
.select()
.single()
if (error) throw new Error(error.message)
cliente = novoCliente
}
// 3. Busca ou cria cliente no ASAAS
const asaasCliente = await buscarOuCriarClienteAsaas({
nome: input.cliente.nome,
email: input.cliente.email,
cpfCnpj: input.cliente.cpfCnpj,
telefone: input.cliente.telefone,
})
// Atualiza asaas_id no banco se necessário
if (!cliente.asaas_id) {
await supabase.from("clientes").update({ asaas_id: asaasCliente.id }).eq("id", cliente.id)
}
// 4. Cria pagamento no ASAAS
const valorReais = produto.preco_centavos / 100
const asaasPayment = await criarPagamentoAsaas({
asaasClienteId: asaasCliente.id,
valor: valorReais,
descricao: `${produto.nome}`,
metodo: input.metodo,
cartao: input.cartao
? {
holderName: input.cartao.holderName,
number: input.cartao.number,
expiryMonth: input.cartao.expiryMonth,
expiryYear: input.cartao.expiryYear,
ccv: input.cartao.ccv,
}
: undefined,
cartaoHolder: input.cartao
? {
name: input.cliente.nome,
email: input.cliente.email,
cpfCnpj: input.cliente.cpfCnpj,
postalCode: input.cartao.postalCode,
addressNumber: input.cartao.addressNumber,
phone: input.cliente.telefone ?? "",
}
: undefined,
})
// 5. Salva pedido no Supabase
const { data: pedido, error: pedidoError } = await supabase
.from("pedidos")
.insert({
cliente_id: cliente.id,
produto_id: produto.id,
valor_centavos: produto.preco_centavos,
metodo_pagamento: input.metodo,
status: "PENDING",
asaas_payment_id: asaasPayment.id,
asaas_invoice_url: asaasPayment.invoiceUrl ?? asaasPayment.bankSlipUrl ?? null,
pix_copia_cola: asaasPayment.pixQrCode?.payload ?? null,
pix_qrcode_url: asaasPayment.pixQrCode?.encodedImage ?? null,
})
.select()
.single()
if (pedidoError) throw new Error(pedidoError.message)
// Dispara notificação PIX via n8n (fire-and-forget)
if (input.metodo === "PIX" && asaasPayment.pixQrCode?.payload) {
const n8nPixUrl = process.env.N8N_PIX_WEBHOOK_URL
if (n8nPixUrl) {
fetch(n8nPixUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
pedido_id: pedido.id,
cliente: {
nome: input.cliente.nome,
email: input.cliente.email,
telefone: input.cliente.telefone ?? "",
},
produto: {
nome: produto.nome,
validade: produto.validade,
},
valor: (produto.preco_centavos / 100).toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
}),
pix_copia_cola: asaasPayment.pixQrCode.payload,
pix_qrcode_base64: asaasPayment.pixQrCode.encodedImage,
}),
}).catch((err) => console.error("n8n pix webhook error:", err))
}
}
return {
pedidoId: pedido.id,
metodo: input.metodo,
invoiceUrl: asaasPayment.invoiceUrl ?? asaasPayment.bankSlipUrl,
pixCopiaECola: asaasPayment.pixQrCode?.payload,
pixQrCodeBase64: asaasPayment.pixQrCode?.encodedImage,
}
}
// =============================================
// PEDIDOS
// =============================================
export async function getPedidos(options: { limit?: number; status?: string } = {}) {
const supabase = createServiceClient()
let query = supabase
.from("pedidos")
.select("*, clientes(nome, email, telefone, cpf_cnpj), produtos(nome, tipo, validade, midia)")
.order("created_at", { ascending: false })
if (options.status) query = query.eq("status", options.status)
if (options.limit) query = query.limit(options.limit)
const { data, error } = await query
if (error) throw new Error(error.message)
return data
}
export async function getPedidoById(id: string) {
const supabase = createServiceClient()
const { data, error } = await supabase
.from("pedidos")
.select("*, clientes(*), produtos(*)")
.eq("id", id)
.single()
if (error) throw new Error(error.message)
return data
}
// =============================================
// AGENDAMENTOS
// =============================================
export async function getAgendamentos(options: { limit?: number } = {}) {
const supabase = createServiceClient()
let query = supabase
.from("agendamentos")
.select("*, clientes(nome, email), produtos(nome)")
.order("data_hora", { ascending: true })
if (options.limit) query = query.limit(options.limit)
const { data, error } = await query
if (error) throw new Error(error.message)
return data
}
export async function criarAgendamento(data: {
clienteId: string
produtoId: string
pedidoId?: string
dataHora: string
observacoes?: string
}) {
const supabase = createServiceClient()
const { data: agendamento, error } = await supabase
.from("agendamentos")
.insert({
cliente_id: data.clienteId,
produto_id: data.produtoId,
pedido_id: data.pedidoId,
data_hora: data.dataHora,
observacoes: data.observacoes,
})
.select()
.single()
if (error) throw new Error(error.message)
revalidatePath("/admin")
return agendamento
}
// =============================================
// CUPONS
// =============================================
export async function getCupomDestaque() {
const supabase = createServiceClient()
const hoje = new Date().toISOString().slice(0, 10)
const { data } = await supabase
.from("cupons")
.select("*")
.eq("ativo", true)
.eq("destaque", true)
.gte("validade", hoje)
.order("created_at", { ascending: false })
.limit(1)
.single()
return data ?? null
}
export async function getCupons() {
const supabase = createServiceClient()
const { data, error } = await supabase
.from("cupons")
.select("*")
.order("created_at", { ascending: false })
if (error) throw new Error(error.message)
return data
}
export async function criarCupom(input: {
codigo: string
descricao: string
percentual: number
validade: string
destaque: boolean
}) {
const supabase = createServiceClient()
const { error } = await supabase.from("cupons").insert({
codigo: input.codigo.toUpperCase(),
descricao: input.descricao,
percentual: input.percentual,
validade: input.validade,
ativo: true,
destaque: input.destaque,
})
if (error) throw new Error(error.message)
revalidatePath("/admin")
revalidatePath("/")
}
export async function atualizarCupom(id: string, input: Partial<{
codigo: string
descricao: string
percentual: number
validade: string
ativo: boolean
destaque: boolean
}>) {
const supabase = createServiceClient()
const { error } = await supabase.from("cupons").update(input).eq("id", id)
if (error) throw new Error(error.message)
revalidatePath("/admin")
revalidatePath("/")
}
export async function deletarCupom(id: string) {
const supabase = createServiceClient()
const { error } = await supabase.from("cupons").delete().eq("id", id)
if (error) throw new Error(error.message)
revalidatePath("/admin")
revalidatePath("/")
}

207
lib/asaas.ts Normal file
View File

@@ -0,0 +1,207 @@
// =============================================
// ASAAS — Integração com gateway de pagamentos
// =============================================
const ASAAS_API_URL =
process.env.ASAAS_ENV === "production"
? "https://www.asaas.com/api/v3"
: "https://sandbox.asaas.com/api/v3"
const ASAAS_API_KEY = process.env.ASAAS_API_KEY!
async function asaasFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${ASAAS_API_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
access_token: ASAAS_API_KEY,
...options.headers,
},
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(`ASAAS API error ${res.status}: ${JSON.stringify(error)}`)
}
return res.json()
}
// =============================================
// CLIENTES
// =============================================
export type AsaasCliente = {
id: string
name: string
email: string
phone?: string
cpfCnpj?: string
}
export async function criarClienteAsaas(data: {
nome: string
email: string
cpfCnpj: string
telefone?: string
}): Promise<AsaasCliente> {
return asaasFetch<AsaasCliente>("/customers", {
method: "POST",
body: JSON.stringify({
name: data.nome,
email: data.email,
cpfCnpj: data.cpfCnpj,
phone: data.telefone,
}),
})
}
export async function buscarOuCriarClienteAsaas(data: {
nome: string
email: string
cpfCnpj: string
telefone?: string
}): Promise<AsaasCliente> {
// Tenta encontrar cliente existente por CPF/CNPJ
const lista = await asaasFetch<{ data: AsaasCliente[] }>(
`/customers?cpfCnpj=${data.cpfCnpj}&limit=1`
)
if (lista.data.length > 0) return lista.data[0]
return criarClienteAsaas(data)
}
// =============================================
// PAGAMENTOS
// =============================================
export type MetodoPagamento = "PIX" | "BOLETO" | "CREDIT_CARD"
export type AsaasPaymentInput = {
asaasClienteId: string
valor: number // em reais (ex: 199.00)
descricao: string
metodo: MetodoPagamento
vencimento?: string // YYYY-MM-DD, obrigatório para boleto
cartao?: {
holderName: string
number: string
expiryMonth: string
expiryYear: string
ccv: string
}
cartaoHolder?: {
name: string
email: string
cpfCnpj: string
postalCode: string
addressNumber: string
phone: string
}
}
export type AsaasPayment = {
id: string
status: string
value: number
netValue: number
billingType: string
invoiceUrl?: string
bankSlipUrl?: string
pixQrCode?: {
encodedImage: string
payload: string
expirationDate: string
}
}
export async function criarPagamentoAsaas(input: AsaasPaymentInput): Promise<AsaasPayment> {
const vencimento =
input.vencimento ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
const body: Record<string, unknown> = {
customer: input.asaasClienteId,
billingType: input.metodo,
value: input.valor,
dueDate: vencimento,
description: input.descricao,
}
if (input.metodo === "CREDIT_CARD" && input.cartao && input.cartaoHolder) {
body.creditCard = {
holderName: input.cartao.holderName,
number: input.cartao.number,
expiryMonth: input.cartao.expiryMonth,
expiryYear: input.cartao.expiryYear,
ccv: input.cartao.ccv,
}
body.creditCardHolderInfo = {
name: input.cartaoHolder.name,
email: input.cartaoHolder.email,
cpfCnpj: input.cartaoHolder.cpfCnpj,
postalCode: input.cartaoHolder.postalCode,
addressNumber: input.cartaoHolder.addressNumber,
phone: input.cartaoHolder.phone,
}
}
const payment = await asaasFetch<AsaasPayment>("/payments", {
method: "POST",
body: JSON.stringify(body),
})
// Para PIX, busca o QR Code
if (input.metodo === "PIX") {
const pix = await asaasFetch<{ encodedImage: string; payload: string; expirationDate: string }>(
`/payments/${payment.id}/pixQrCode`
)
payment.pixQrCode = pix
}
return payment
}
export async function buscarPagamentoAsaas(paymentId: string): Promise<AsaasPayment> {
return asaasFetch<AsaasPayment>(`/payments/${paymentId}`)
}
// =============================================
// WEBHOOK — valida e processa evento
// =============================================
export type AsaasWebhookEvent = {
event: string
payment: {
id: string
status: string
value: number
billingType: string
paymentDate?: string
}
}
export function parseWebhookPayload(body: unknown): AsaasWebhookEvent {
return body as AsaasWebhookEvent
}
// Mapeia status ASAAS → status interno
export function mapearStatus(
asaasStatus: string
): "PENDING" | "RECEIVED" | "CONFIRMED" | "OVERDUE" | "REFUNDED" | "CANCELLED" {
const map: Record<string, "PENDING" | "RECEIVED" | "CONFIRMED" | "OVERDUE" | "REFUNDED" | "CANCELLED"> = {
PENDING: "PENDING",
RECEIVED: "RECEIVED",
CONFIRMED: "CONFIRMED",
OVERDUE: "OVERDUE",
REFUND_REQUESTED: "REFUNDED",
REFUNDED: "REFUNDED",
CHARGEBACK_REQUESTED: "CANCELLED",
CHARGEBACK_DISPUTE: "CANCELLED",
AWAITING_CHARGEBACK_REVERSAL: "CANCELLED",
DUNNING_REQUESTED: "OVERDUE",
DUNNING_RECEIVED: "RECEIVED",
AWAITING_RISK_ANALYSIS: "PENDING",
}
return map[asaasStatus] ?? "PENDING"
}

48
lib/auth-context.tsx Normal file
View File

@@ -0,0 +1,48 @@
"use client"
import type React from "react"
import { createContext, useContext, useState, useEffect } from "react"
interface AuthContextType {
isLoggedIn: boolean
login: (username: string, password: string) => Promise<boolean>
logout: () => void
}
const AuthContext = createContext<AuthContextType>({
isLoggedIn: false,
login: async () => false,
logout: () => {},
})
export const useAuth = () => useContext(AuthContext)
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [isLoggedIn, setIsLoggedIn] = useState(false)
useEffect(() => {
const token = localStorage.getItem("isLoggedIn")
if (token === "true") {
setIsLoggedIn(true)
}
}, [])
const login = async (username: string, password: string) => {
const adminUser = process.env.NEXT_PUBLIC_ADMIN_USER ?? "admin"
const adminPass = process.env.NEXT_PUBLIC_ADMIN_PASSWORD ?? "admin"
if (username === adminUser && password === adminPass) {
localStorage.setItem("isLoggedIn", "true")
setIsLoggedIn(true)
return true
}
return false
}
const logout = () => {
localStorage.removeItem("isLoggedIn")
setIsLoggedIn(false)
}
return <AuthContext.Provider value={{ isLoggedIn, login, logout }}>{children}</AuthContext.Provider>
}

11
lib/config.ts Normal file
View File

@@ -0,0 +1,11 @@
// Configurações da instância — todas via variáveis de ambiente
// Copie .env.example para .env.local e preencha
export const appConfig = {
name: process.env.NEXT_PUBLIC_APP_NAME ?? "Checkout",
logoUrl: process.env.NEXT_PUBLIC_APP_LOGO_URL ?? null,
primaryColor: process.env.NEXT_PUBLIC_APP_PRIMARY_COLOR ?? "#1d4ed8",
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL ?? null,
supportWhatsapp: process.env.NEXT_PUBLIC_SUPPORT_WHATSAPP ?? null,
afterPaymentRedirect: process.env.NEXT_PUBLIC_AFTER_PAYMENT_REDIRECT ?? null,
}

29
lib/schema.ts Normal file
View File

@@ -0,0 +1,29 @@
import { z } from "zod"
export const productSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
images: z.array(z.string()).optional(),
price: z.object({
id: z.string().nullable(),
amount: z.number().nullable(),
display_amount: z.string().nullable(),
}),
metadata: z
.object({
tipo: z.string().optional(),
validade: z.string().optional(),
midia: z.string().optional(),
categoria: z.string().optional(),
cor: z.string().optional(),
excerpt: z.string().optional(),
beneficios: z.string().optional(),
})
.optional(),
})
export const productListSchema = z.object({
data: z.array(productSchema),
has_more: z.boolean(),
})

81
lib/supabase.ts Normal file
View File

@@ -0,0 +1,81 @@
import { createClient } from "@supabase/supabase-js"
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
// Client-side (browser)
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// Server-side com service role (para Server Actions e API routes)
export function createServiceClient() {
return createClient(supabaseUrl, process.env.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
})
}
// Tipos do banco
export type Produto = {
id: string
nome: string
descricao: string | null
tipo: "PF" | "PJ" | "SSL" | "NFe"
validade: string
midia: "Token" | "Cartão" | "Nuvem" | "Sem mídia" | null
preco_centavos: number
ativo: boolean
imagem_url: string | null
created_at: string
updated_at: string
}
export type Cliente = {
id: string
nome: string
email: string
telefone: string | null
cpf_cnpj: string | null
asaas_id: string | null
created_at: string
updated_at: string
}
export type Pedido = {
id: string
cliente_id: string | null
produto_id: string | null
valor_centavos: number
metodo_pagamento: "PIX" | "BOLETO" | "CREDIT_CARD" | null
status: "PENDING" | "RECEIVED" | "CONFIRMED" | "OVERDUE" | "REFUNDED" | "CANCELLED"
asaas_payment_id: string | null
asaas_invoice_url: string | null
pix_copia_cola: string | null
pix_qrcode_url: string | null
due_date: string | null
paid_at: string | null
created_at: string
updated_at: string
}
export type Cupom = {
id: string
codigo: string
descricao: string
percentual: number
validade: string
ativo: boolean
destaque: boolean
created_at: string
updated_at: string
}
export type Agendamento = {
id: string
cliente_id: string | null
produto_id: string | null
pedido_id: string | null
data_hora: string
status: "AGUARDANDO" | "CONFIRMADO" | "CANCELADO" | "CONCLUIDO"
observacoes: string | null
created_at: string
updated_at: string
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}