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
+49
-22
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user