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>
227 lines
6.7 KiB
TypeScript
227 lines
6.7 KiB
TypeScript
"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>
|
|
)
|
|
}
|