diff --git a/ABACATEPAY_SETUP.md b/ABACATEPAY_SETUP.md index 13d68fb..aae7729 100644 --- a/ABACATEPAY_SETUP.md +++ b/ABACATEPAY_SETUP.md @@ -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 diff --git a/TESTE_ABACATEPAY.md b/TESTE_ABACATEPAY.md new file mode 100644 index 0000000..0c7fcc6 --- /dev/null +++ b/TESTE_ABACATEPAY.md @@ -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 + diff --git a/app/[locale]/apoiar/page.tsx b/app/[locale]/apoiar/page.tsx index 9bcef60..a3dbc8a 100644 --- a/app/[locale]/apoiar/page.tsx +++ b/app/[locale]/apoiar/page.tsx @@ -179,6 +179,15 @@ export default async function SupportPage({ icon="coffee" badge={t("tiers.coffee.badge")} /> + - diff --git a/app/api/donations/create/route.ts b/app/api/donations/create/route.ts index faa1434..f7bf29d 100644 --- a/app/api/donations/create/route.ts +++ b/app/api/donations/create/route.ts @@ -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 = [ + "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 } ); diff --git a/app/api/webhooks/abacatepay/route.ts b/app/api/webhooks/abacatepay/route.ts index 4908f46..eea8e0b 100644 --- a/app/api/webhooks/abacatepay/route.ts +++ b/app/api/webhooks/abacatepay/route.ts @@ -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 }); diff --git a/components/donation-card.tsx b/components/donation-card.tsx index 3e5334a..8497d24 100644 --- a/components/donation-card.tsx +++ b/components/donation-card.tsx @@ -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(null); + const [timeRemaining, setTimeRemaining] = useState(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({ {title} {description} - {amount ? ( + {amount && ( R$ {amount.toFixed(2)} - ) : ( - setCustomAmount(Number(e.target.value))} - min={5} - max={10000} - step={5} - className="text-center text-2xl font-bold" - placeholder="R$ 10,00" - /> )} {loading ? ( @@ -148,6 +315,206 @@ export function DonationCard({ + {/* Modal de Escolha (Anônimo vs Identificado) */} + + + + + Como deseja contribuir? + + + + + {/* Card Anônimo */} + + + + + + Doação Anônima + + + Rápido e sem cadastro. Gere o PIX imediatamente. + + + Continuar Anônimo + + + + {/* Card Identificado */} + + + Recomendado + + + + + + Quero me Identificar + + + Ajude-nos a agradecer e oferecer benefícios + + + + + + Agradecimento personalizado por email + + + + Acesso antecipado a novos recursos + + + + Histórico de suas contribuições + + + + + Identificar-me + + + + + + + {/* Modal de Formulário do Doador Identificado */} + + + + Informações para Doação + + + + {error && ( + + {error} + + )} + + + + Nome *{" "} + + (min. 3 caracteres) + + + setDonorName(e.target.value)} + placeholder="Seu nome ou apelido" + maxLength={50} + /> + + + + Email * + setDonorEmail(e.target.value)} + placeholder="seu@email.com" + /> + + + + + Telefone *{" "} + (com DDD) + + setDonorPhone(formatPhone(e.target.value))} + placeholder="(11) 98888-7777" + maxLength={15} + /> + + + + + CPF/CNPJ *{" "} + + (formato automático) + + + { + 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} + /> + + + + + Mensagem{" "} + + (opcional, máx. 280 chars) + + + { + if (e.target.value.length <= 280) { + setDonorMessage(e.target.value); + } + }} + placeholder="Deixe uma mensagem para a equipe..." + rows={3} + maxLength={280} + /> + + {donorMessage.length}/280 + + + + + { + setShowDonorForm(false); + setShowChoiceModal(true); + setError(null); + }} + variant="outline" + className="flex-1" + disabled={loading} + > + Voltar + + + {loading ? ( + <> + + Processando... + > + ) : ( + "Gerar PIX" + )} + + + + + + + {/* Modal de QR Code PIX */} @@ -159,7 +526,11 @@ export function DonationCard({ {pixData && ( - + @@ -168,7 +539,7 @@ export function DonationCard({ @@ -192,9 +563,11 @@ export function DonationCard({ - - O pagamento expira em 30 minutos - + {timeRemaining && ( + + ⏱️ Tempo restante: {timeRemaining} + + )} {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + } +); +Textarea.displayName = "Textarea"; + +export { Textarea }; diff --git a/lib/abacatepay.ts b/lib/abacatepay.ts index 6f3384e..e905304 100644 --- a/lib/abacatepay.ts +++ b/lib/abacatepay.ts @@ -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 { + 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 { 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 { - 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 { 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 { + // 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 }; diff --git a/types/donation.ts b/types/donation.ts index 1807420..642de12 100644 --- a/types/donation.ts +++ b/types/donation.ts @@ -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; }
{description}
+ Rápido e sem cadastro. Gere o PIX imediatamente. +
+ Ajude-nos a agradecer e oferecer benefícios +
+ {donorMessage.length}/280 +
- O pagamento expira em 30 minutos -
+ ⏱️ Tempo restante: {timeRemaining} +