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,197 @@
"use client"
import { useState, useMemo } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { PedidoDetailModal } from "@/components/pedido-detail-modal"
import { Search, Download } from "lucide-react"
import type { Pedido } from "@/lib/supabase"
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",
}
const ALL_STATUSES = ["PENDING", "RECEIVED", "CONFIRMED", "OVERDUE", "REFUNDED", "CANCELLED"]
type PedidoRich = Pedido & {
clientes?: { nome: string; email: string; telefone?: string | null; cpf_cnpj?: string | null } | null
produtos?: { nome: string; tipo: string; validade?: string; midia?: string | null } | null
asaas_invoice_url?: string | null
pix_copia_cola?: string | null
pix_qrcode_url?: string | null
paid_at?: string | null
}
export function TransactionTable({ transactions }: { transactions: PedidoRich[] }) {
const [busca, setBusca] = useState("")
const [filtroStatus, setFiltroStatus] = useState<string | null>(null)
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoRich | null>(null)
const fmt = (v: number) =>
(v / 100).toLocaleString("pt-BR", { style: "currency", currency: "BRL" })
const filtrados = useMemo(() => {
return transactions.filter((t) => {
const matchStatus = !filtroStatus || t.status === filtroStatus
const q = busca.toLowerCase()
const matchBusca =
!q ||
t.id.toLowerCase().includes(q) ||
(t.clientes?.nome ?? "").toLowerCase().includes(q) ||
(t.clientes?.email ?? "").toLowerCase().includes(q) ||
(t.produtos?.nome ?? "").toLowerCase().includes(q)
return matchStatus && matchBusca
})
}, [transactions, busca, filtroStatus])
function exportCSV() {
const header = ["ID", "Cliente", "Email", "Produto", "Valor", "Método", "Status", "Data"]
const rows = filtrados.map((t) => [
t.id,
t.clientes?.nome ?? "",
t.clientes?.email ?? "",
t.produtos?.nome ?? "",
(t.valor_centavos / 100).toFixed(2),
t.metodo_pagamento ?? "",
t.status,
new Date(t.created_at).toLocaleDateString("pt-BR"),
])
const csv = [header, ...rows].map((r) => r.map((c) => `"${c}"`).join(",")).join("\n")
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `pedidos-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
if (transactions.length === 0) {
return (
<div className="rounded-md border p-8 text-center text-muted-foreground">
Nenhum pedido encontrado.
</div>
)
}
return (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex flex-wrap gap-2 items-center justify-between">
<div className="flex gap-2 flex-wrap">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar cliente, produto, ID..."
className="pl-8 h-9 w-64"
value={busca}
onChange={(e) => setBusca(e.target.value)}
/>
</div>
<div className="flex gap-1 flex-wrap">
<Button
size="sm"
variant={filtroStatus === null ? "default" : "outline"}
className="h-9 text-xs"
onClick={() => setFiltroStatus(null)}
>
Todos
</Button>
{ALL_STATUSES.map((s) => (
<Button
key={s}
size="sm"
variant={filtroStatus === s ? "default" : "outline"}
className="h-9 text-xs"
onClick={() => setFiltroStatus(filtroStatus === s ? null : s)}
>
{statusLabel[s]}
</Button>
))}
</div>
</div>
<Button size="sm" variant="outline" className="h-9" onClick={exportCSV}>
<Download className="mr-2 h-4 w-4" />
Exportar CSV
</Button>
</div>
{/* Contagem */}
<p className="text-xs text-muted-foreground">
{filtrados.length} pedido{filtrados.length !== 1 ? "s" : ""} encontrado{filtrados.length !== 1 ? "s" : ""}
</p>
{/* Tabela */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Cliente</TableHead>
<TableHead>Produto</TableHead>
<TableHead>Valor</TableHead>
<TableHead>Método</TableHead>
<TableHead>Status</TableHead>
<TableHead>Data</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtrados.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
Nenhum pedido encontrado com esses filtros.
</TableCell>
</TableRow>
) : (
filtrados.map((t) => (
<TableRow
key={t.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => setPedidoSelecionado(t)}
>
<TableCell className="font-mono text-xs">{t.id.slice(0, 8).toUpperCase()}</TableCell>
<TableCell className="max-w-[120px] truncate">{t.clientes?.nome ?? "—"}</TableCell>
<TableCell className="max-w-[140px] truncate">{t.produtos?.nome ?? "—"}</TableCell>
<TableCell className="font-medium">{fmt(t.valor_centavos)}</TableCell>
<TableCell className="text-xs">{t.metodo_pagamento ?? "—"}</TableCell>
<TableCell>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[t.status] ?? ""}`}>
{statusLabel[t.status] ?? t.status}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{new Date(t.created_at).toLocaleDateString("pt-BR")}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Modal detalhe */}
{pedidoSelecionado && (
<PedidoDetailModal
pedido={pedidoSelecionado as Parameters<typeof PedidoDetailModal>[0]["pedido"]}
open={!!pedidoSelecionado}
onClose={() => setPedidoSelecionado(null)}
/>
)}
</div>
)
}