feat: enhance AbacatePay donation system with updated docs and form fields
Updated ABACATEPAY_SETUP.md with comprehensive setup guide, including environment variables, API key instructions, webhook configuration, donation flow, and field details for better integration clarity. Added required fields (phone, CPF/CNPJ) to the donation form in app/[locale]/apoiar/page.tsx to collect essential donor information and improve compliance. This ensures smoother setup and more robust donation processing.
This commit is contained in:
1 parent
00501e6699
commit
ef521876eb
9 files changed
+922
-168
No files matched your search
+411
-38
@@ -1,22 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Heart, Coffee, Pizza, Loader2, Copy, Check } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Heart,
|
||||
Coffee,
|
||||
Pizza,
|
||||
Loader2,
|
||||
Copy,
|
||||
Check,
|
||||
Lock,
|
||||
User,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import QRCode from "react-qr-code";
|
||||
import { DonationTier, DonationResponse } from "@/types/donation";
|
||||
|
||||
interface DonationCardProps extends DonationTier {}
|
||||
|
||||
export function DonationCard({
|
||||
id,
|
||||
amount,
|
||||
title,
|
||||
description,
|
||||
@@ -24,14 +35,27 @@ export function DonationCard({
|
||||
badge,
|
||||
highlighted,
|
||||
}: DonationCardProps) {
|
||||
const [customAmount, setCustomAmount] = useState(amount || 10);
|
||||
// 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<{
|
||||
code: string;
|
||||
qrCode: string;
|
||||
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 icons = {
|
||||
coffee: Coffee,
|
||||
@@ -41,43 +65,197 @@ export function DonationCard({
|
||||
|
||||
const Icon = icons[icon];
|
||||
|
||||
const handleDonate = async () => {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Atualizar timer imediatamente
|
||||
updateTimer();
|
||||
|
||||
// Atualizar timer a cada segundo
|
||||
const timerInterval = setInterval(updateTimer, 1000);
|
||||
|
||||
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({ amount: customAmount }),
|
||||
body: JSON.stringify({
|
||||
tier: id,
|
||||
anonymous: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const data: DonationResponse = await response.json();
|
||||
|
||||
if (data.success && data.paymentUrl) {
|
||||
// Redirecionar para página de pagamento do AbacatePay
|
||||
window.location.href = data.paymentUrl;
|
||||
} else if (data.success && data.pixCode && data.pixQrCode) {
|
||||
// Fallback: mostrar modal com QR Code se disponível
|
||||
if (data.success && data.brCode && data.brCodeBase64 && data.pixId) {
|
||||
setPixData({
|
||||
code: data.pixCode,
|
||||
qrCode: data.pixQrCode,
|
||||
pixId: data.pixId,
|
||||
brCode: data.brCode,
|
||||
brCodeBase64: data.brCodeBase64,
|
||||
expiresAt: data.expiresAt,
|
||||
});
|
||||
setShowPixModal(true);
|
||||
} else {
|
||||
console.error("Erro ao criar doação:", data.error);
|
||||
alert("Erro ao processar doação. Tente novamente.");
|
||||
setError(data.error || "Erro ao processar doação");
|
||||
setShowChoiceModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Donation error:", error);
|
||||
alert("Erro ao processar doação. Tente novamente.");
|
||||
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,
|
||||
});
|
||||
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?.code) {
|
||||
if (pixData?.brCode) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(pixData.code);
|
||||
await navigator.clipboard.writeText(pixData.brCode);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
@@ -113,27 +291,16 @@ export function DonationCard({
|
||||
<h3 className="text-2xl font-bold">{title}</h3>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
|
||||
{amount ? (
|
||||
{amount && (
|
||||
<div className="text-4xl font-bold text-primary">
|
||||
R$ {amount.toFixed(2)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(Number(e.target.value))}
|
||||
min={5}
|
||||
max={10000}
|
||||
step={5}
|
||||
className="text-center text-2xl font-bold"
|
||||
placeholder="R$ 10,00"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleDonate}
|
||||
onClick={handleDonateClick}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -148,6 +315,206 @@ export function DonationCard({
|
||||
</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>
|
||||
@@ -159,7 +526,11 @@ export function DonationCard({
|
||||
{pixData && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<QRCode value={pixData.qrCode} size={256} />
|
||||
<img
|
||||
src={pixData.brCodeBase64}
|
||||
alt="QR Code PIX"
|
||||
className="w-64 h-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -168,7 +539,7 @@ export function DonationCard({
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={pixData.code}
|
||||
value={pixData.brCode}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
@@ -192,9 +563,11 @@ export function DonationCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
O pagamento expira em 30 minutos
|
||||
</p>
|
||||
{timeRemaining && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
⏱️ Tempo restante: {timeRemaining}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
Reference in new issue
Block a user