Files
pagodera/components/donation-card.tsx
T
Evandro Fonseca Junior 2697e2d1f5 feat: enhance webhook for payment confirmation and add frontend detection
Improve webhook handling in AbacatePay route to accurately detect paid events, support anonymous donations, and log structured data for future database and email integrations. Add frontend logic in DonationCard to check payment confirmation via localStorage for real-time UI updates without API polling.
2025-10-26 21:41:05 -03:00

690 lines
21 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Heart,
Coffee,
Pizza,
Loader2,
Copy,
Check,
Lock,
User,
CheckCircle,
CheckCircle2,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DonationTier, DonationResponse } from "@/types/donation";
interface DonationCardProps extends DonationTier {}
export function DonationCard({
id,
amount,
title,
description,
icon,
badge,
highlighted,
}: DonationCardProps) {
// Estados para escolha inicial
const [showChoiceModal, setShowChoiceModal] = useState(false);
const [showDonorForm, setShowDonorForm] = useState(false);
const [donorName, setDonorName] = useState("");
const [donorEmail, setDonorEmail] = useState("");
const [donorPhone, setDonorPhone] = useState("");
const [donorTaxId, setDonorTaxId] = useState("");
const [donorMessage, setDonorMessage] = useState("");
// Estados para PIX
const [loading, setLoading] = useState(false);
const [showPixModal, setShowPixModal] = useState(false);
const [pixData, setPixData] = useState<{
pixId: string;
brCode: string;
brCodeBase64: string;
expiresAt?: string;
} | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
const [paymentConfirmed, setPaymentConfirmed] = useState(false);
const [showThankYouModal, setShowThankYouModal] = useState(false);
const icons = {
coffee: Coffee,
pizza: Pizza,
heart: Heart,
};
const Icon = icons[icon];
// Atualizar timer e verificar pagamento automaticamente
useEffect(() => {
if (!pixData?.expiresAt || !pixData?.pixId) {
setTimeRemaining(null);
return;
}
const updateTimer = () => {
try {
const expires = new Date(pixData!.expiresAt!);
const now = new Date();
const diff = expires.getTime() - now.getTime();
if (diff <= 0) {
setTimeRemaining("Expirado");
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
setTimeRemaining(`${minutes}:${seconds.toString().padStart(2, "0")}`);
} catch {
setTimeRemaining(null);
}
};
// Verificar pagamento via localStorage
const checkPaymentConfirmation = () => {
const savedPixId = localStorage.getItem("currentPixId");
const confirmedPixIds = localStorage.getItem("confirmedPixIds");
if (savedPixId && confirmedPixIds) {
try {
const confirmed = JSON.parse(confirmedPixIds);
if (confirmed.includes(savedPixId)) {
console.log("✅ Pagamento confirmado!");
setPaymentConfirmed(true);
setTimeout(() => {
setShowPixModal(false);
setShowThankYouModal(true);
// Limpar
localStorage.removeItem("currentPixId");
}, 1000);
clearInterval(timerInterval);
}
} catch (e) {
console.error("Erro ao verificar pagamento:", e);
}
}
};
// Atualizar timer imediatamente
updateTimer();
// Atualizar timer e verificar pagamento a cada 2 segundos
const timerInterval = setInterval(() => {
updateTimer();
checkPaymentConfirmation();
}, 2000);
return () => clearInterval(timerInterval);
}, [pixData?.expiresAt, pixData?.pixId]);
// Funções de formatação
const formatCPF = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 11) {
return numbers
.replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d{1,2})$/, "$1-$2");
}
return value; // Não formatar se for CNPJ (14 dígitos)
};
const formatCNPJ = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length > 11) {
return numbers
.replace(/^(\d{2})(\d)/, "$1.$2")
.replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3")
.replace(/\.(\d{3})(\d)/, ".$1/$2")
.replace(/(\d{4})(\d)/, "$1-$2");
}
return value;
};
const formatPhone = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 10) {
// Telefone fixo (10 dígitos)
return numbers.replace(/^(\d{2})(\d{4})(\d{4})$/, "($1) $2-$3");
} else {
// Celular (11 dígitos)
return numbers.replace(/^(\d{2})(\d{5})(\d{4})$/, "($1) $2-$3");
}
};
const handleDonateClick = () => {
setShowChoiceModal(true);
setError(null);
};
const handleAnonymousDonation = async () => {
setLoading(true);
setError(null);
setShowChoiceModal(false);
try {
const response = await fetch("/api/donations/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tier: id,
anonymous: true,
}),
});
const data: DonationResponse = await response.json();
if (data.success && data.brCode && data.brCodeBase64 && data.pixId) {
setPixData({
pixId: data.pixId,
brCode: data.brCode,
brCodeBase64: data.brCodeBase64,
expiresAt: data.expiresAt,
});
// Salvar pixId no localStorage para verificação
localStorage.setItem("currentPixId", data.pixId);
setShowPixModal(true);
} else {
setError(data.error || "Erro ao processar doação");
setShowChoiceModal(true);
}
} catch (error) {
console.error("Donation error:", error);
setError("Erro ao processar doação. Tente novamente.");
setShowChoiceModal(true);
} finally {
setLoading(false);
}
};
const handleIdentifiedClick = () => {
setShowChoiceModal(false);
setShowDonorForm(true);
setError(null);
};
const handleSubmitForm = async () => {
// Validação
if (!donorName.trim() || donorName.trim().length < 3) {
setError("Nome deve ter no mínimo 3 caracteres");
return;
}
if (!donorPhone.trim()) {
setError("Telefone é obrigatório");
return;
}
if (!donorEmail.trim()) {
setError("Email é obrigatório");
return;
}
if (!donorTaxId.trim()) {
setError("CPF/CNPJ é obrigatório");
return;
}
if (donorMessage && donorMessage.length > 280) {
setError("Mensagem deve ter no máximo 280 caracteres");
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch("/api/donations/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tier: id,
anonymous: false,
donorName: donorName.trim(),
donorEmail: donorEmail.trim(),
donorPhone: donorPhone.replace(/\D/g, ""), // Apenas números
donorTaxId: donorTaxId.replace(/\D/g, ""), // Apenas números
donorMessage: donorMessage.trim() || undefined,
}),
});
const data: DonationResponse = await response.json();
if (data.success && data.brCode && data.brCodeBase64 && data.pixId) {
setPixData({
pixId: data.pixId,
brCode: data.brCode,
brCodeBase64: data.brCodeBase64,
expiresAt: data.expiresAt,
});
// Salvar pixId no localStorage
localStorage.setItem("currentPixId", data.pixId);
setShowDonorForm(false);
setShowPixModal(true);
} else {
setError(data.error || "Erro ao processar doação");
}
} catch (error) {
console.error("Donation error:", error);
setError("Erro ao processar doação. Tente novamente.");
} finally {
setLoading(false);
}
};
const handleCopyPixCode = async () => {
if (pixData?.brCode) {
try {
await navigator.clipboard.writeText(pixData.brCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Erro ao copiar código PIX:", error);
}
}
};
return (
<>
<div
className={`
relative p-8 rounded-2xl border bg-card backdrop-blur-lg
hover:scale-105 transition-all duration-300
${
highlighted
? "border-primary/50 shadow-[0_0_30px_rgba(34,197,94,0.3)]"
: "border-border/50"
}
`}
>
{badge && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary">
{badge}
</Badge>
)}
<div className="flex flex-col items-center text-center space-y-4">
<div className="p-4 rounded-full bg-muted/50 border border-border/30">
<Icon className="w-12 h-12 text-primary" />
</div>
<h3 className="text-2xl font-bold">{title}</h3>
<p className="text-muted-foreground">{description}</p>
{amount && (
<div className="text-4xl font-bold text-primary">
R$ {amount.toFixed(2)}
</div>
)}
<Button
size="lg"
className="w-full"
onClick={handleDonateClick}
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Processando...
</>
) : (
"Apoiar com PIX"
)}
</Button>
</div>
</div>
{/* Modal de Escolha (Anônimo vs Identificado) */}
<Dialog open={showChoiceModal} onOpenChange={setShowChoiceModal}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="text-center text-2xl">
Como deseja contribuir?
</DialogTitle>
</DialogHeader>
<div className="grid md:grid-cols-2 gap-4 mt-6">
{/* Card Anônimo */}
<div
className="p-6 rounded-xl border border-border/50 bg-card hover:border-primary/30 transition-all cursor-pointer"
onClick={handleAnonymousDonation}
>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-full bg-muted">
<Lock className="h-5 w-5" />
</div>
<h3 className="text-xl font-bold">Doação Anônima</h3>
</div>
<p className="text-muted-foreground mb-4">
Rápido e sem cadastro. Gere o PIX imediatamente.
</p>
<Button className="w-full" variant="outline" disabled={loading}>
Continuar Anônimo
</Button>
</div>
{/* Card Identificado */}
<div
className="p-6 rounded-xl border-2 border-primary/50 bg-primary/5 hover:border-primary transition-all cursor-pointer relative"
onClick={handleIdentifiedClick}
>
<Badge className="absolute -top-3 right-6 bg-primary">
Recomendado
</Badge>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<h3 className="text-xl font-bold">Quero me Identificar</h3>
</div>
<p className="text-muted-foreground mb-4">
Ajude-nos a agradecer e oferecer benefícios
</p>
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-primary" />
<span>Agradecimento personalizado por email</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-primary" />
<span>Acesso antecipado a novos recursos</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-primary" />
<span>Histórico de suas contribuições</span>
</div>
</div>
<Button className="w-full" disabled={loading}>
Identificar-me
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Modal de Formulário do Doador Identificado */}
<Dialog open={showDonorForm} onOpenChange={setShowDonorForm}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Informações para Doação</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/50 rounded-lg text-destructive text-sm">
{error}
</div>
)}
<div>
<label className="text-sm font-medium mb-2 block">
Nome *{" "}
<span className="text-muted-foreground text-xs">
(min. 3 caracteres)
</span>
</label>
<Input
value={donorName}
onChange={(e) => setDonorName(e.target.value)}
placeholder="Seu nome ou apelido"
maxLength={50}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Email *</label>
<Input
type="email"
value={donorEmail}
onChange={(e) => setDonorEmail(e.target.value)}
placeholder="[email protected]"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
Telefone *{" "}
<span className="text-muted-foreground text-xs">(com DDD)</span>
</label>
<Input
value={donorPhone}
onChange={(e) => setDonorPhone(formatPhone(e.target.value))}
placeholder="(11) 98888-7777"
maxLength={15}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
CPF/CNPJ *{" "}
<span className="text-muted-foreground text-xs">
(formato automático)
</span>
</label>
<Input
value={donorTaxId}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, "");
if (value.length <= 11) {
setDonorTaxId(formatCPF(value));
} else {
setDonorTaxId(formatCNPJ(value));
}
}}
placeholder="000.000.000-00 ou 00.000.000/0000-00"
maxLength={18}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
Mensagem{" "}
<span className="text-muted-foreground text-xs">
(opcional, máx. 280 chars)
</span>
</label>
<Textarea
value={donorMessage}
onChange={(e) => {
if (e.target.value.length <= 280) {
setDonorMessage(e.target.value);
}
}}
placeholder="Deixe uma mensagem para a equipe..."
rows={3}
maxLength={280}
/>
<p className="text-xs text-muted-foreground text-right mt-1">
{donorMessage.length}/280
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
setShowDonorForm(false);
setShowChoiceModal(true);
setError(null);
}}
variant="outline"
className="flex-1"
disabled={loading}
>
Voltar
</Button>
<Button
onClick={handleSubmitForm}
className="flex-1"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processando...
</>
) : (
"Gerar PIX"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Modal de QR Code PIX */}
<Dialog open={showPixModal} onOpenChange={setShowPixModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-center">
Escaneie o QR Code para Pagar
</DialogTitle>
</DialogHeader>
{pixData && (
<div className="space-y-4">
<div className="flex justify-center p-4 bg-white rounded-lg">
<img
src={pixData.brCodeBase64}
alt="QR Code PIX"
className="w-64 h-64"
/>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground text-center">
Ou copie o código PIX:
</p>
<div className="flex gap-2">
<Input
value={pixData.brCode}
readOnly
className="font-mono text-sm"
/>
<Button
onClick={handleCopyPixCode}
variant="outline"
size="sm"
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copiado!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copiar
</>
)}
</Button>
</div>
</div>
{timeRemaining && (
<p className="text-xs text-center text-muted-foreground">
⏱️ Tempo restante: {timeRemaining}
</p>
)}
<div className="flex gap-2">
<Button
onClick={() => setShowPixModal(false)}
variant="outline"
className="flex-1"
>
Fechar
</Button>
<Button
onClick={() => (window.location.href = "/apoiar/obrigado")}
className="flex-1"
>
Já Paguei
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Modal de Agradecimento */}
<Dialog open={showThankYouModal} onOpenChange={setShowThankYouModal}>
<DialogContent className="max-w-md">
<div className="text-center space-y-6 py-4">
{/* Ícone de sucesso animado */}
<div className="flex justify-center">
<div className="relative">
<div className="absolute inset-0 bg-green-500/20 rounded-full animate-ping" />
<div className="relative p-4 rounded-full bg-green-500/10 border-4 border-green-500">
<CheckCircle2 className="w-16 h-16 text-green-500" />
</div>
</div>
</div>
{/* Título e mensagem */}
<div>
<h2 className="text-2xl font-bold mb-2">
Pagamento Confirmado! 🎉
</h2>
<p className="text-muted-foreground">
Obrigado pela sua contribuição! Cada doação nos ajuda a manter o
projeto funcionando.
</p>
</div>
{/* Mensagem especial */}
<div className="p-4 rounded-lg bg-primary/5 border border-primary/20">
<p className="text-sm text-muted-foreground">
Sua doação faz toda a diferença para nossa comunidade. Muito
obrigado pelo apoio! ❤️
</p>
</div>
{/* Botões */}
<div className="flex gap-3">
<Button
onClick={() => {
setShowThankYouModal(false);
window.location.href = "/apoiar/obrigado";
}}
className="flex-1"
size="lg"
>
Ver Página de Agradecimento
</Button>
<Button
onClick={() => {
setShowThankYouModal(false);
window.location.href = "/";
}}
variant="outline"
className="flex-1"
size="lg"
>
Voltar ao Início
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}