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.
690 lines
21 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|