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:
Evandro Fonseca Junior committed 2025-10-26 21:16:59 -03:00
1 parent 00501e6699
commit ef521876eb
9 files changed
+922 -168

No files matched your search

+49 -22
View File
@@ -1,43 +1,70 @@
# Variáveis de Ambiente - AbacatePay
# Configuração - Sistema de Doações PIX via AbacatePay
## Configuração para Desenvolvimento/Teste
## Variáveis de Ambiente
### Desenvolvimento/Teste
Crie o arquivo `.env.local` na raiz do projeto:
```env
# Chave de API para ambiente de teste
ABACATE_TEST_APIKEY=your_test_api_key_here
ABACATE_TEST_APIKEY=abc_test_sua_chave_aqui
# ID da conta AbacatePay
ABACATEPAY_ACCOUNT_ID=your_account_id_here
# URLs da aplicação
# URL base da aplicação
NEXT_PUBLIC_BASE_URL=http://localhost:3000
NEXT_PUBLIC_ABACATEPAY_WEBHOOK_URL=http://localhost:3000/api/webhooks/abacatepay
```
## Configuração para Produção
### Produção
```env
# Chave de API para ambiente de produção (usado automaticamente quando NODE_ENV=production)
ABACATE_APIKEY=your_production_api_key_here
# Chave de API para ambiente de produção
ABACATE_APIKEY=abc_live_sua_chave_aqui
# ID da conta AbacatePay
ABACATEPAY_ACCOUNT_ID=your_account_id_here
# URLs da aplicação
# URL base da aplicação
NEXT_PUBLIC_BASE_URL=https://seudominio.com
NEXT_PUBLIC_ABACATEPAY_WEBHOOK_URL=https://seudominio.com/api/webhooks/abacatepay
```
## Como Funciona
O sistema automaticamente escolhe a chave correta baseado no ambiente:
- **Desenvolvimento/Teste**: Usa `ABACATE_TEST_APIKEY`
- **Desenvolvimento**: Usa `ABACATE_TEST_APIKEY`
- **Produção**: Usa `ABACATE_APIKEY` (quando `NODE_ENV=production`)
## Obtenção das Chaves
## Obtenção da Chave API
1. Acesse o painel do AbacatePay
2. Vá em "Configurações" > "API Keys"
3. Gere uma chave para teste e outra para produção
4. Configure as URLs de webhook no painel do AbacatePay
1. Acesse https://abacatepay.com
2. Crie uma conta gratuita
3. Vá em "Configurações" > "API Keys"
4. Copie sua chave de teste (começa com `abc_test_...`)
5. Cole no arquivo `.env.local`
## Endpoint do Webhook
Configure no painel do AbacatePay:
**Desenvolvimento**: `http://localhost:3000/api/webhooks/abacatepay`
**Produção**: `https://seudominio.com/api/webhooks/abacatepay`
## Estrutura do Sistema
### Fluxo de Doação
1. Usuário clica em "Apoiar com PIX"
2. Preenche formulário: nome (obrigatório), email (opcional), mensagem (opcional)
3. Sistema gera QR Code PIX via endpoint `/pixQrCode/create`
4. Usuário escaneia QR Code ou copia código PIX
5. Pagamento é processado no app bancário
6. Webhook confirma pagamento automaticamente
7. Dados são registrados para futuros agradecimentos e benefícios
### Campos Coletados
- **Nome**: obrigatório (mínimo 3 caracteres) - para dar os devidos agradecimentos
- **Email**: opcional - para enviar agradecimento por email
- **Mensagem**: opcional (máximo 280 caracteres) - mensagem personalizada para a equipe
### Expiração
- QR Code expira em 10 minutos
- Timer exibido no modal para o usuário acompanhar
+162
View File
@@ -0,0 +1,162 @@
# Guia de Teste - Sistema de Doações AbacatePay
## 🔧 Configuração Inicial
### 1. Criar conta no AbacatePay
- Acesse: https://abacatepay.com
- Crie uma conta gratuita
- Acesse o Dashboard
### 2. Obter chave de API
- No Dashboard, vá para "API Keys" ou "Configurações"
- Copie sua **TEST API KEY** (começa com `abc_test_...`)
- Você também precisará da **PRODUCTION API KEY** quando for para produção
### 3. Configurar produtos (OPCIONAL)
O sistema de PIX não requer produtos pré-cadastrados no AbacatePay. Os valores são enviados diretamente na requisição.
Os produtos já estão definidos no código:
- Café: R$ 5,00 (500 centavos)
- Lanche: R$ 10,00 (1000 centavos)
- Almoço: R$ 25,00 (2500 centavos)
### 4. Configurar variáveis de ambiente
Crie um arquivo `.env.local` na raiz do projeto:
```env
# AbacatePay API Keys
ABACATE_TEST_APIKEY=sua_chave_de_teste_aqui
# URL base da aplicação
NEXT_PUBLIC_BASE_URL=http://localhost:3000
```
**IMPORTANTE**: Não é necessário `ABACATEPAY_ACCOUNT_ID` nem produtos pré-cadastrados. O sistema PIX funciona diretamente com a API.
## 🧪 Testando o Sistema
### Ambiente de Teste (Development)
1. **Inicie o servidor de desenvolvimento**:
```bash
pnpm run dev
```
2. **Acesse a página de doações**:
```
http://localhost:3000/pt/apoiar
```
3. **Clique em um dos cards de doação**
4. **Verifique os logs no terminal**:
```
🔧 AbacatePay: Using TEST environment
🔍 AbacatePay Request: {
"frequency": "ONE_TIME",
"methods": ["PIX"],
"products": [
{
"externalId": "prod_...",
"name": "Doação Café",
"quantity": 1,
"price": 500
}
],
...
}
🔍 AbacatePay Response Status: 200
```
5. **Resultado esperado**:
- Status 200 ✅
- Redirecionamento para página de pagamento do AbacatePay
- OU exibição de QR Code PIX (modal)
### Estrutura da Requisição Correta
```json
{
"frequency": "ONE_TIME",
"methods": ["PIX"],
"products": [
{
"externalId": "prod_6ryJmhfZGNJGjNaYaA1YqhDn",
"name": "Doação Café",
"quantity": 1,
"price": 500
}
],
"returnUrl": "http://localhost:3000/apoiar/obrigado",
"completionUrl": "http://localhost:3000/api/donations/verify"
}
```
**Campos obrigatórios:**
- ✅ `externalId`: ID do produto pré-cadastrado
- ✅ `name`: Nome do produto
- ✅ `quantity`: Quantidade (sempre 1)
- ✅ `price`: Valor em centavos (R$ 5,00 = 500)
## 🔍 Troubleshooting
### Erro: "Route POST:/v1/billing not found"
**Solução**: Endpoint correto é `/billing/create`, não `/billing`
### Erro: "must have required property 'name'"
**Solução**: Adicionar campo `name` no array de produtos
### Erro: "Product not found"
**Solução**: Verificar se os IDs dos produtos estão corretos e existem no painel
### Erro: "Invalid API key"
**Solução**:
- Verifique se a chave está correta no `.env.local`
- Certifique-se de usar `ABACATE_TEST_APIKEY` em development
- Reinicie o servidor após alterar variáveis de ambiente
### Erro: "401 Unauthorized"
**Solução**: Chave de API expirada ou inválida. Gere uma nova no painel.
## 📋 Checklist de Validação
- [ ] Conta criada no AbacatePay
- [ ] API Key de teste obtida
- [ ] 3 produtos criados no painel (R$ 5, R$ 10, R$ 25)
- [ ] IDs dos produtos atualizados em `types/donation.ts`
- [ ] Arquivo `.env.local` criado com as variáveis
- [ ] Servidor reiniciado após configurar variáveis
- [ ] Página `/pt/apoiar` carrega sem erros
- [ ] Logs mostram "Using TEST environment"
- [ ] Clique em um card envia requisição
- [ ] Status 200 retornado
- [ ] Redirecionamento funciona ou QR Code é exibido
## 🚀 Deploy em Produção
Quando estiver pronto para produção:
1. Configure as variáveis no Vercel/Netlify:
```
ABACATE_APIKEY=abc_live_...
ABACATEPAY_ACCOUNT_ID=...
NEXT_PUBLIC_BASE_URL=https://seusite.com
```
2. O sistema detecta automaticamente o ambiente:
```typescript
const isProduction = process.env.NODE_ENV === "production";
const apiKey = isProduction
? process.env.ABACATE_APIKEY
: process.env.ABACATE_TEST_APIKEY;
```
3. Teste novamente em produção antes de divulgar
## 📞 Suporte
- Documentação: https://docs.abacatepay.com
- API Reference: https://abacatepay.readme.io
- Dashboard: https://abacatepay.com/dashboard
+9 -7
View File
@@ -179,6 +179,15 @@ export default async function SupportPage({
icon="coffee"
badge={t("tiers.coffee.badge")}
/>
<DonationCard
id="snack"
amount={10}
title="Um Lanche"
description="Equivale a ~20 minutos de desenvolvimento"
icon="pizza"
badge="Popular"
highlighted
/>
<DonationCard
id="lunch"
amount={25}
@@ -186,13 +195,6 @@ export default async function SupportPage({
description={t("tiers.lunch.description")}
icon="pizza"
badge={t("tiers.lunch.badge")}
highlighted
/>
<DonationCard
id="custom"
title={t("tiers.custom.title")}
description={t("tiers.custom.description")}
icon="heart"
/>
</div>
</div>
+111 -25
View File
@@ -1,49 +1,135 @@
import { NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay";
import { DonationData } from "@/types/donation";
import { DONATION_PRODUCTS, DonorData } from "@/types/donation";
export async function POST(request: NextRequest) {
try {
const body: DonationData = await request.json();
const body = await request.json();
// Validação básica
if (!body.amount || body.amount < 5 || body.amount > 10000) {
// Validar tier
const validTiers: Array<keyof typeof DONATION_PRODUCTS> = [
"coffee",
"snack",
"lunch",
];
if (!body.tier || !validTiers.includes(body.tier)) {
return NextResponse.json(
{
success: false,
error: "Valor deve estar entre R$ 5,00 e R$ 10.000,00",
},
{ success: false, error: "Tier de doação inválido" },
{ status: 400 }
);
}
const billing = await abacatePay.createDonationBilling({
amount: body.amount,
donorEmail: body.donorEmail,
donorName: body.donorName,
message: body.message,
});
const product =
DONATION_PRODUCTS[body.tier as keyof typeof DONATION_PRODUCTS];
// AbacatePay pode retornar diferentes formatos
const pixCode = billing.pixCode || billing.qrCode;
const pixQrCode = billing.pixQrCode || billing.qrCode;
const paymentUrl = billing.url || billing.paymentUrl;
let pixData;
// Fluxo: Anônimo ou Identificado
if (body.anonymous === true) {
// Doação anônima - sem customer
pixData = await abacatePay.createPixQrCode({
amount: product.price,
description: product.name,
expiresIn: 600,
});
console.log("🔍 PIX criado (anônimo):", pixData);
console.log("📝 Registro de doação anônima:", {
type: "donation_created",
pixId: pixData.id,
amount: product.price,
tier: body.tier,
anonymous: true,
createdAt: new Date().toISOString(),
});
} else {
// Doação identificada - validar campos obrigatórios
if (!body.donorName || body.donorName.trim().length < 3) {
return NextResponse.json(
{ success: false, error: "Nome é obrigatório (mínimo 3 caracteres)" },
{ status: 400 }
);
}
if (!body.donorPhone || body.donorPhone.trim().length === 0) {
return NextResponse.json(
{ success: false, error: "Telefone é obrigatório" },
{ status: 400 }
);
}
if (!body.donorEmail) {
return NextResponse.json(
{
success: false,
error: "Email é obrigatório para doação identificada",
},
{ status: 400 }
);
}
if (!body.donorTaxId || body.donorTaxId.trim().length === 0) {
return NextResponse.json(
{ success: false, error: "CPF/CNPJ é obrigatório" },
{ status: 400 }
);
}
if (body.donorMessage && body.donorMessage.length > 280) {
return NextResponse.json(
{
success: false,
error: "Mensagem deve ter no máximo 280 caracteres",
},
{ status: 400 }
);
}
pixData = await abacatePay.createPixQrCode({
amount: product.price,
description: product.name,
expiresIn: 600,
customer: {
name: body.donorName.trim(),
email: body.donorEmail.trim(),
cellphone: body.donorPhone.trim(),
taxId: body.donorTaxId.trim(),
},
});
console.log("🔍 PIX criado (identificado):", pixData);
console.log("📝 Registro de doação identificada:", {
type: "donation_created",
pixId: pixData.id,
amount: product.price,
tier: body.tier,
anonymous: false,
donor: {
name: body.donorName,
email: body.donorEmail,
phone: body.donorPhone,
taxId: body.donorTaxId,
message: body.donorMessage,
},
createdAt: new Date().toISOString(),
});
}
return NextResponse.json({
success: true,
billingId: billing.id,
paymentUrl, // URL para redirecionar
pixCode,
pixQrCode,
expiresAt: billing.expiresAt,
pixId: pixData.id,
brCode: pixData.brCode,
brCodeBase64: pixData.brCodeBase64,
expiresAt: pixData.expiresAt,
});
} catch (error) {
console.error("Donation creation error:", error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : "Erro interno do servidor",
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
);
+37 -13
View File
@@ -15,23 +15,47 @@ export async function POST(request: NextRequest) {
const event = JSON.parse(payload);
if (event.type === "billing.paid") {
// Processar doação confirmada
console.log("Donation confirmed:", event.data.id);
console.log("🔔 Webhook recebido:", JSON.stringify(event, null, 2));
// Aqui você pode:
// 1. Salvar no banco de dados
// 2. Enviar email de agradecimento
// 3. Atualizar estatísticas
// 4. Notificar a equipe
// Capturar todos os tipos de eventos de pagamento
const isPaidEvent =
event.type === "pix.paid" ||
event.type === "billing.paid" ||
event.type === "payment.paid" ||
event.data?.status === "PAID";
// Por enquanto, apenas log
console.log("Donation details:", {
id: event.data.id,
if (isPaidEvent) {
console.log("✅ Doação confirmada via webhook:", event.data.id);
// Log estruturado para futura persistência em banco de dados
const donationRecord = {
type: "donation_paid",
pixId: event.data.id,
amount: event.data.amount,
customer: event.data.customer,
paidAt: event.data.paidAt || new Date().toISOString(),
createdAt: new Date().toISOString(),
metadata: event.data.metadata,
paidAt: event.data.paidAt,
});
};
console.log("📝 Registro de pagamento:", donationRecord);
// TODO: Quando implementar banco de dados, salvar aqui:
// await db.donations.create(donationRecord);
// TODO: Quando implementar email, enviar aqui:
// if (event.data.customer?.email) {
// await sendThankYouEmail({
// to: event.data.customer.email,
// donorName: event.data.customer.name,
// amount: event.data.amount,
// });
// }
// TODO: Atualizar estatísticas
// await updateDonationStats(event.data.amount);
} else {
console.log("ℹ️ Evento de webhook (não é pagamento):", event.type);
}
return NextResponse.json({ success: true });
+411 -38
View File
@@ -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
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };
+87 -59
View File
@@ -1,6 +1,5 @@
interface AbacatePayConfig {
apiKey: string;
accountId: string;
baseUrl?: string;
}
@@ -19,13 +18,22 @@ interface BillingResponse {
id: string;
amount: number;
status: "PENDING" | "PAID" | "EXPIRED" | "CANCELLED";
pixCode?: string;
pixQrCode?: string;
qrCode?: string;
brCode?: string; // Código PIX copia-e-cola
brCodeBase64?: string; // QR Code em Base64
expiresAt?: string;
createdAt: string;
url?: string; // URL para redirecionamento
paymentUrl?: string; // URL alternativa de pagamento
}
interface CreatePixParams {
amount: number; // em centavos
description: string;
expiresIn?: number; // em segundos, padrão 600 (10 min)
customer?: {
name: string;
email?: string;
cellphone?: string;
taxId?: string;
};
}
class AbacatePayClient {
@@ -38,6 +46,46 @@ class AbacatePayClient {
};
}
async createPixQrCode(params: CreatePixParams): Promise<BillingResponse> {
const requestBody: any = {
amount: params.amount,
expiresIn: params.expiresIn || 600, // 10 minutos por padrão
description: params.description,
};
if (params.customer) {
requestBody.customer = params.customer;
}
console.log("🔍 PIX Request:", JSON.stringify(requestBody, null, 2));
const response = await fetch(`${this.config.baseUrl}/pixQrCode/create`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
console.log("🔍 PIX Response Status:", response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error("🔍 PIX Error:", errorData);
throw new Error(`Failed to create PIX: ${JSON.stringify(errorData)}`);
}
const result = await response.json();
console.log("🔍 PIX Success:", result);
if (result.error) {
throw new Error(result.error);
}
return result.data;
}
async createBilling(params: CreateBillingParams): Promise<BillingResponse> {
const response = await fetch(`${this.config.baseUrl}/billing`, {
method: "POST",
@@ -46,7 +94,6 @@ class AbacatePayClient {
"Content-Type": "application/json",
},
body: JSON.stringify({
account_id: this.config.accountId,
amount: params.amount * 100, // centavos
metadata: params.metadata,
return_url: params.returnUrl,
@@ -64,56 +111,6 @@ class AbacatePayClient {
return response.json();
}
async createDonationBilling(params: {
amount: number;
donorEmail?: string;
donorName?: string;
message?: string;
}): Promise<BillingResponse> {
const requestBody = {
products: [
{
external_id: `donation-${Date.now()}`,
name: "Doação - Tibia Trade Pagodera",
quantity: 1,
price: Math.round(params.amount * 100), // R$ para centavos
},
],
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/apoiar/obrigado`,
completion_url: `${process.env.NEXT_PUBLIC_BASE_URL}/api/donations/verify`,
};
console.log("🔍 AbacatePay Request URL:", `${this.config.baseUrl}/billing`);
console.log(
"🔍 AbacatePay Request Body:",
JSON.stringify(requestBody, null, 2)
);
const response = await fetch(`${this.config.baseUrl}/billing`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
console.log("🔍 AbacatePay Response Status:", response.status);
console.log("🔍 AbacatePay Response OK:", response.ok);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error("🔍 AbacatePay Error Response:", errorData);
throw new Error(
`Failed to create billing: ${errorData.message || response.statusText}`
);
}
const result = await response.json();
console.log("🔍 AbacatePay Success Response:", result);
return result;
}
async getBillingStatus(billingId: string): Promise<BillingResponse> {
const response = await fetch(
`${this.config.baseUrl}/billing/${billingId}`,
@@ -133,7 +130,39 @@ class AbacatePayClient {
);
}
return response.json();
const result = await response.json();
// AbacatePay retorna { error: null, data: { ... } }
if (result.error) {
throw new Error(result.error);
}
// Retornar apenas o data
return result.data;
}
async getPixStatus(pixId: string): Promise<BillingResponse> {
// AbacatePay usa o endpoint /billing/{id} para todos os tipos de billing
const response = await fetch(`${this.config.baseUrl}/billing/${pixId}`, {
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
`Failed to get PIX status: ${errorData.message || response.statusText}`
);
}
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
return result.data;
}
verifyWebhookSignature(payload: string, signature: string): boolean {
@@ -159,7 +188,6 @@ if (process.env.NODE_ENV !== "production") {
export const abacatePay = new AbacatePayClient({
apiKey,
accountId: process.env.ABACATEPAY_ACCOUNT_ID || "demo_account",
});
export type { CreateBillingParams, BillingResponse };
+33 -4
View File
@@ -15,12 +15,41 @@ export interface DonationData {
message?: string;
}
export const DONATION_PRODUCTS = {
coffee: {
id: "prod_6ryJmhfZGNJGjNaYaA1YqhDn",
name: "Doação Café",
amount: 5,
price: 500, // centavos
},
snack: {
id: "prod_FpLxKWgmHXbj2Ghwxa0BsFph",
name: "Doação Lanche",
amount: 10,
price: 1000,
},
lunch: {
id: "prod_czgg0YjKBp3GpsFYpSp3M1TZ",
name: "Doação Almoço",
amount: 25,
price: 2500,
},
} as const;
export type DonationTierId = keyof typeof DONATION_PRODUCTS;
export interface DonorData {
name: string;
phone: string;
email?: string;
message?: string;
}
export interface DonationResponse {
success: boolean;
billingId?: string;
paymentUrl?: string;
pixCode?: string;
pixQrCode?: string;
pixId?: string;
brCode?: string;
brCodeBase64?: string;
expiresAt?: string;
error?: string;
}