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
+916
-162
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
|
```env
|
||||||
# Chave de API para ambiente de teste
|
# 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
|
# URL base da aplicação
|
||||||
ABACATEPAY_ACCOUNT_ID=your_account_id_here
|
|
||||||
|
|
||||||
# URLs da aplicação
|
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
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
|
```env
|
||||||
# Chave de API para ambiente de produção (usado automaticamente quando NODE_ENV=production)
|
# Chave de API para ambiente de produção
|
||||||
ABACATE_APIKEY=your_production_api_key_here
|
ABACATE_APIKEY=abc_live_sua_chave_aqui
|
||||||
|
|
||||||
# ID da conta AbacatePay
|
# URL base da aplicação
|
||||||
ABACATEPAY_ACCOUNT_ID=your_account_id_here
|
|
||||||
|
|
||||||
# URLs da aplicação
|
|
||||||
NEXT_PUBLIC_BASE_URL=https://seudominio.com
|
NEXT_PUBLIC_BASE_URL=https://seudominio.com
|
||||||
NEXT_PUBLIC_ABACATEPAY_WEBHOOK_URL=https://seudominio.com/api/webhooks/abacatepay
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Como Funciona
|
## Como Funciona
|
||||||
|
|
||||||
O sistema automaticamente escolhe a chave correta baseado no ambiente:
|
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`)
|
- **Produção**: Usa `ABACATE_APIKEY` (quando `NODE_ENV=production`)
|
||||||
|
|
||||||
## Obtenção das Chaves
|
## Obtenção da Chave API
|
||||||
|
|
||||||
1. Acesse o painel do AbacatePay
|
1. Acesse https://abacatepay.com
|
||||||
2. Vá em "Configurações" > "API Keys"
|
2. Crie uma conta gratuita
|
||||||
3. Gere uma chave para teste e outra para produção
|
3. Vá em "Configurações" > "API Keys"
|
||||||
4. Configure as URLs de webhook no painel do AbacatePay
|
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"
|
icon="coffee"
|
||||||
badge={t("tiers.coffee.badge")}
|
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
|
<DonationCard
|
||||||
id="lunch"
|
id="lunch"
|
||||||
amount={25}
|
amount={25}
|
||||||
@@ -186,13 +195,6 @@ export default async function SupportPage({
|
|||||||
description={t("tiers.lunch.description")}
|
description={t("tiers.lunch.description")}
|
||||||
icon="pizza"
|
icon="pizza"
|
||||||
badge={t("tiers.lunch.badge")}
|
badge={t("tiers.lunch.badge")}
|
||||||
highlighted
|
|
||||||
/>
|
|
||||||
<DonationCard
|
|
||||||
id="custom"
|
|
||||||
title={t("tiers.custom.title")}
|
|
||||||
description={t("tiers.custom.description")}
|
|
||||||
icon="heart"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,49 +1,135 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { abacatePay } from "@/lib/abacatepay";
|
import { abacatePay } from "@/lib/abacatepay";
|
||||||
import { DonationData } from "@/types/donation";
|
import { DONATION_PRODUCTS, DonorData } from "@/types/donation";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body: DonationData = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Validação básica
|
// Validar tier
|
||||||
if (!body.amount || body.amount < 5 || body.amount > 10000) {
|
const validTiers: Array<keyof typeof DONATION_PRODUCTS> = [
|
||||||
|
"coffee",
|
||||||
|
"snack",
|
||||||
|
"lunch",
|
||||||
|
];
|
||||||
|
if (!body.tier || !validTiers.includes(body.tier)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Tier de doação inválido" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const product =
|
||||||
|
DONATION_PRODUCTS[body.tier as keyof typeof DONATION_PRODUCTS];
|
||||||
|
|
||||||
|
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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: "Valor deve estar entre R$ 5,00 e R$ 10.000,00",
|
error: "Email é obrigatório para doação identificada",
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const billing = await abacatePay.createDonationBilling({
|
if (!body.donorTaxId || body.donorTaxId.trim().length === 0) {
|
||||||
amount: body.amount,
|
return NextResponse.json(
|
||||||
donorEmail: body.donorEmail,
|
{ success: false, error: "CPF/CNPJ é obrigatório" },
|
||||||
donorName: body.donorName,
|
{ status: 400 }
|
||||||
message: body.message,
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// AbacatePay pode retornar diferentes formatos
|
console.log("🔍 PIX criado (identificado):", pixData);
|
||||||
const pixCode = billing.pixCode || billing.qrCode;
|
|
||||||
const pixQrCode = billing.pixQrCode || billing.qrCode;
|
console.log("📝 Registro de doação identificada:", {
|
||||||
const paymentUrl = billing.url || billing.paymentUrl;
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
billingId: billing.id,
|
pixId: pixData.id,
|
||||||
paymentUrl, // URL para redirecionar
|
brCode: pixData.brCode,
|
||||||
pixCode,
|
brCodeBase64: pixData.brCodeBase64,
|
||||||
pixQrCode,
|
expiresAt: pixData.expiresAt,
|
||||||
expiresAt: billing.expiresAt,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Donation creation error:", error);
|
console.error("Donation creation error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
error instanceof Error ? error.message : "Erro interno do servidor",
|
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,23 +15,47 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const event = JSON.parse(payload);
|
const event = JSON.parse(payload);
|
||||||
|
|
||||||
if (event.type === "billing.paid") {
|
console.log("🔔 Webhook recebido:", JSON.stringify(event, null, 2));
|
||||||
// Processar doação confirmada
|
|
||||||
console.log("Donation confirmed:", event.data.id);
|
|
||||||
|
|
||||||
// Aqui você pode:
|
// Capturar todos os tipos de eventos de pagamento
|
||||||
// 1. Salvar no banco de dados
|
const isPaidEvent =
|
||||||
// 2. Enviar email de agradecimento
|
event.type === "pix.paid" ||
|
||||||
// 3. Atualizar estatísticas
|
event.type === "billing.paid" ||
|
||||||
// 4. Notificar a equipe
|
event.type === "payment.paid" ||
|
||||||
|
event.data?.status === "PAID";
|
||||||
|
|
||||||
// Por enquanto, apenas log
|
if (isPaidEvent) {
|
||||||
console.log("Donation details:", {
|
console.log("✅ Doação confirmada via webhook:", event.data.id);
|
||||||
id: 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,
|
amount: event.data.amount,
|
||||||
|
customer: event.data.customer,
|
||||||
|
paidAt: event.data.paidAt || new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
metadata: event.data.metadata,
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
|
|||||||
+409
-36
@@ -1,22 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import QRCode from "react-qr-code";
|
|
||||||
import { DonationTier, DonationResponse } from "@/types/donation";
|
import { DonationTier, DonationResponse } from "@/types/donation";
|
||||||
|
|
||||||
interface DonationCardProps extends DonationTier {}
|
interface DonationCardProps extends DonationTier {}
|
||||||
|
|
||||||
export function DonationCard({
|
export function DonationCard({
|
||||||
|
id,
|
||||||
amount,
|
amount,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -24,14 +35,27 @@ export function DonationCard({
|
|||||||
badge,
|
badge,
|
||||||
highlighted,
|
highlighted,
|
||||||
}: DonationCardProps) {
|
}: 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 [loading, setLoading] = useState(false);
|
||||||
const [showPixModal, setShowPixModal] = useState(false);
|
const [showPixModal, setShowPixModal] = useState(false);
|
||||||
const [pixData, setPixData] = useState<{
|
const [pixData, setPixData] = useState<{
|
||||||
code: string;
|
pixId: string;
|
||||||
qrCode: string;
|
brCode: string;
|
||||||
|
brCodeBase64: string;
|
||||||
|
expiresAt?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
coffee: Coffee,
|
coffee: Coffee,
|
||||||
@@ -41,43 +65,197 @@ export function DonationCard({
|
|||||||
|
|
||||||
const Icon = icons[icon];
|
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);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setShowChoiceModal(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/donations/create", {
|
const response = await fetch("/api/donations/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ amount: customAmount }),
|
body: JSON.stringify({
|
||||||
|
tier: id,
|
||||||
|
anonymous: true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data: DonationResponse = await response.json();
|
const data: DonationResponse = await response.json();
|
||||||
|
|
||||||
if (data.success && data.paymentUrl) {
|
if (data.success && data.brCode && data.brCodeBase64 && data.pixId) {
|
||||||
// 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
|
|
||||||
setPixData({
|
setPixData({
|
||||||
code: data.pixCode,
|
pixId: data.pixId,
|
||||||
qrCode: data.pixQrCode,
|
brCode: data.brCode,
|
||||||
|
brCodeBase64: data.brCodeBase64,
|
||||||
|
expiresAt: data.expiresAt,
|
||||||
});
|
});
|
||||||
setShowPixModal(true);
|
setShowPixModal(true);
|
||||||
} else {
|
} else {
|
||||||
console.error("Erro ao criar doação:", data.error);
|
setError(data.error || "Erro ao processar doação");
|
||||||
alert("Erro ao processar doação. Tente novamente.");
|
setShowChoiceModal(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Donation error:", 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyPixCode = async () => {
|
const handleCopyPixCode = async () => {
|
||||||
if (pixData?.code) {
|
if (pixData?.brCode) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(pixData.code);
|
await navigator.clipboard.writeText(pixData.brCode);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -113,27 +291,16 @@ export function DonationCard({
|
|||||||
<h3 className="text-2xl font-bold">{title}</h3>
|
<h3 className="text-2xl font-bold">{title}</h3>
|
||||||
<p className="text-muted-foreground">{description}</p>
|
<p className="text-muted-foreground">{description}</p>
|
||||||
|
|
||||||
{amount ? (
|
{amount && (
|
||||||
<div className="text-4xl font-bold text-primary">
|
<div className="text-4xl font-bold text-primary">
|
||||||
R$ {amount.toFixed(2)}
|
R$ {amount.toFixed(2)}
|
||||||
</div>
|
</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
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleDonate}
|
onClick={handleDonateClick}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -148,6 +315,206 @@ export function DonationCard({
|
|||||||
</div>
|
</div>
|
||||||
</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}>
|
<Dialog open={showPixModal} onOpenChange={setShowPixModal}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -159,7 +526,11 @@ export function DonationCard({
|
|||||||
{pixData && (
|
{pixData && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -168,7 +539,7 @@ export function DonationCard({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={pixData.code}
|
value={pixData.brCode}
|
||||||
readOnly
|
readOnly
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -192,9 +563,11 @@ export function DonationCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{timeRemaining && (
|
||||||
<p className="text-xs text-center text-muted-foreground">
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
O pagamento expira em 30 minutos
|
⏱️ Tempo restante: {timeRemaining}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<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 {
|
interface AbacatePayConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
accountId: string;
|
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,13 +18,22 @@ interface BillingResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
status: "PENDING" | "PAID" | "EXPIRED" | "CANCELLED";
|
status: "PENDING" | "PAID" | "EXPIRED" | "CANCELLED";
|
||||||
pixCode?: string;
|
brCode?: string; // Código PIX copia-e-cola
|
||||||
pixQrCode?: string;
|
brCodeBase64?: string; // QR Code em Base64
|
||||||
qrCode?: string;
|
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
createdAt: 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 {
|
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> {
|
async createBilling(params: CreateBillingParams): Promise<BillingResponse> {
|
||||||
const response = await fetch(`${this.config.baseUrl}/billing`, {
|
const response = await fetch(`${this.config.baseUrl}/billing`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -46,7 +94,6 @@ class AbacatePayClient {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
account_id: this.config.accountId,
|
|
||||||
amount: params.amount * 100, // centavos
|
amount: params.amount * 100, // centavos
|
||||||
metadata: params.metadata,
|
metadata: params.metadata,
|
||||||
return_url: params.returnUrl,
|
return_url: params.returnUrl,
|
||||||
@@ -64,56 +111,6 @@ class AbacatePayClient {
|
|||||||
return response.json();
|
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> {
|
async getBillingStatus(billingId: string): Promise<BillingResponse> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${this.config.baseUrl}/billing/${billingId}`,
|
`${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 {
|
verifyWebhookSignature(payload: string, signature: string): boolean {
|
||||||
@@ -159,7 +188,6 @@ if (process.env.NODE_ENV !== "production") {
|
|||||||
|
|
||||||
export const abacatePay = new AbacatePayClient({
|
export const abacatePay = new AbacatePayClient({
|
||||||
apiKey,
|
apiKey,
|
||||||
accountId: process.env.ABACATEPAY_ACCOUNT_ID || "demo_account",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type { CreateBillingParams, BillingResponse };
|
export type { CreateBillingParams, BillingResponse };
|
||||||
+33
-4
@@ -15,12 +15,41 @@ export interface DonationData {
|
|||||||
message?: string;
|
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 {
|
export interface DonationResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
billingId?: string;
|
pixId?: string;
|
||||||
paymentUrl?: string;
|
brCode?: string;
|
||||||
pixCode?: string;
|
brCodeBase64?: string;
|
||||||
pixQrCode?: string;
|
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user