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:
226
components/pedido-detail-modal.tsx
Normal file
226
components/pedido-detail-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user