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

View File

@@ -0,0 +1,226 @@
"use client"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"
import { RefreshCw, ExternalLink, Copy } from "lucide-react"
const statusColors: Record<string, string> = {
PENDING: "bg-yellow-100 text-yellow-800",
RECEIVED: "bg-green-100 text-green-800",
CONFIRMED: "bg-green-100 text-green-800",
OVERDUE: "bg-red-100 text-red-800",
REFUNDED: "bg-gray-100 text-gray-800",
CANCELLED: "bg-red-100 text-red-800",
}
const statusLabel: Record<string, string> = {
PENDING: "Pendente",
RECEIVED: "Pago",
CONFIRMED: "Confirmado",
OVERDUE: "Vencido",
REFUNDED: "Estornado",
CANCELLED: "Cancelado",
}
type Pedido = {
id: string
status: string
valor_centavos: number
metodo_pagamento: string | null
asaas_payment_id: string | null
asaas_invoice_url: string | null
pix_copia_cola: string | null
pix_qrcode_url: string | null
paid_at: string | null
created_at: string
clientes: {
nome: string
email: string
telefone: string | null
cpf_cnpj: string | null
} | null
produtos: {
nome: string
tipo: string
validade: string
midia: string | null
} | null
}
export function PedidoDetailModal({
pedido: pedidoInicial,
open,
onClose,
}: {
pedido: Pedido
open: boolean
onClose: () => void
}) {
const { toast } = useToast()
const [pedido, setPedido] = useState(pedidoInicial)
const [syncing, setSyncing] = useState(false)
const fmt = (v: number) =>
(v / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
async function syncStatus() {
setSyncing(true)
try {
const res = await fetch(`/api/pedido/${pedido.id}/sync`, { method: "POST" })
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setPedido((p) => ({ ...p, status: data.status }))
toast({ title: `Status atualizado: ${statusLabel[data.status] ?? data.status}` })
} catch (err) {
toast({
title: "Erro ao sincronizar",
description: err instanceof Error ? err.message : "Tente novamente",
variant: "destructive",
})
} finally {
setSyncing(false)
}
}
function copiar(texto: string, label: string) {
navigator.clipboard.writeText(texto)
toast({ title: `${label} copiado!` })
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between gap-2 pr-6">
<span>Pedido <span className="font-mono text-sm">{pedido.id.slice(0, 8).toUpperCase()}</span></span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[pedido.status] ?? ""}`}>
{statusLabel[pedido.status] ?? pedido.status}
</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-5 mt-2">
{/* Cliente */}
<Section title="Cliente">
<Row label="Nome" value={pedido.clientes?.nome} />
<Row label="E-mail" value={pedido.clientes?.email} />
<Row label="Telefone" value={pedido.clientes?.telefone} />
<Row label="CPF/CNPJ" value={pedido.clientes?.cpf_cnpj} />
</Section>
{/* Produto */}
<Section title="Produto">
<Row label="Nome" value={pedido.produtos?.nome} />
<Row label="Tipo" value={pedido.produtos?.tipo} />
<Row label="Validade" value={pedido.produtos?.validade} />
<Row label="Mídia" value={pedido.produtos?.midia} />
</Section>
{/* Pagamento */}
<Section title="Pagamento">
<Row label="Valor" value={fmt(pedido.valor_centavos)} highlight />
<Row label="Método" value={pedido.metodo_pagamento} />
<Row label="ASAAS ID" value={pedido.asaas_payment_id} mono />
<Row
label="Criado em"
value={new Date(pedido.created_at).toLocaleString("pt-BR")}
/>
{pedido.paid_at && (
<Row
label="Pago em"
value={new Date(pedido.paid_at).toLocaleString("pt-BR")}
/>
)}
</Section>
{/* Ações */}
<div className="flex flex-wrap gap-2 pt-1">
<Button
size="sm"
variant="outline"
onClick={syncStatus}
disabled={syncing}
>
<RefreshCw className={`mr-2 h-3.5 w-3.5 ${syncing ? "animate-spin" : ""}`} />
Sync ASAAS
</Button>
{pedido.asaas_invoice_url && (
<Button
size="sm"
variant="outline"
onClick={() => window.open(pedido.asaas_invoice_url!, "_blank")}
>
<ExternalLink className="mr-2 h-3.5 w-3.5" />
Ver boleto
</Button>
)}
{pedido.pix_copia_cola && (
<Button
size="sm"
variant="outline"
onClick={() => copiar(pedido.pix_copia_cola!, "PIX copia-e-cola")}
>
<Copy className="mr-2 h-3.5 w-3.5" />
Copiar PIX
</Button>
)}
{pedido.asaas_payment_id && (
<Button
size="sm"
variant="outline"
onClick={() =>
window.open(
`https://www.asaas.com/index?tab=payment&id=${pedido.asaas_payment_id}`,
"_blank"
)
}
>
<ExternalLink className="mr-2 h-3.5 w-3.5" />
Ver no ASAAS
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{title}
</p>
<div className="rounded-lg border divide-y text-sm">{children}</div>
</div>
)
}
function Row({
label,
value,
mono,
highlight,
}: {
label: string
value?: string | null
mono?: boolean
highlight?: boolean
}) {
return (
<div className="flex justify-between items-center px-3 py-2 gap-4">
<span className="text-muted-foreground shrink-0">{label}</span>
<span
className={`text-right truncate ${mono ? "font-mono text-xs" : ""} ${highlight ? "font-semibold text-primary" : ""}`}
>
{value ?? "—"}
</span>
</div>
)
}