diff --git a/ABACATEPAY_SETUP.md b/ABACATEPAY_SETUP.md new file mode 100644 index 0000000..aae7729 --- /dev/null +++ b/ABACATEPAY_SETUP.md @@ -0,0 +1,70 @@ +# Configuração - Sistema de Doações PIX via AbacatePay + +## 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=abc_test_sua_chave_aqui + +# URL base da aplicação +NEXT_PUBLIC_BASE_URL=http://localhost:3000 +``` + +### Produção + +```env +# Chave de API para ambiente de produção +ABACATE_APIKEY=abc_live_sua_chave_aqui + +# URL base da aplicação +NEXT_PUBLIC_BASE_URL=https://seudominio.com +``` + +## Como Funciona + +O sistema automaticamente escolhe a chave correta baseado no ambiente: + +- **Desenvolvimento**: Usa `ABACATE_TEST_APIKEY` +- **Produção**: Usa `ABACATE_APIKEY` (quando `NODE_ENV=production`) + +## Obtenção da Chave API + +1. Acesse https://abacatepay.com +2. Crie uma conta gratuita +3. Vá em "Configurações" > "API Keys" +4. Copie sua chave de teste (começa com `abc_test_...`) +5. Cole no arquivo `.env.local` + +## Endpoint do Webhook + +Configure no painel do AbacatePay: + +**Desenvolvimento**: `http://localhost:3000/api/webhooks/abacatepay` +**Produção**: `https://seudominio.com/api/webhooks/abacatepay` + +## Estrutura do Sistema + +### Fluxo de Doação + +1. Usuário clica em "Apoiar com PIX" +2. Preenche formulário: nome (obrigatório), email (opcional), mensagem (opcional) +3. Sistema gera QR Code PIX via endpoint `/pixQrCode/create` +4. Usuário escaneia QR Code ou copia código PIX +5. Pagamento é processado no app bancário +6. Webhook confirma pagamento automaticamente +7. Dados são registrados para futuros agradecimentos e benefícios + +### Campos Coletados + +- **Nome**: obrigatório (mínimo 3 caracteres) - para dar os devidos agradecimentos +- **Email**: opcional - para enviar agradecimento por email +- **Mensagem**: opcional (máximo 280 caracteres) - mensagem personalizada para a equipe + +### Expiração + +- QR Code expira em 10 minutos +- Timer exibido no modal para o usuário acompanhar diff --git a/TESTE_ABACATEPAY.md b/TESTE_ABACATEPAY.md new file mode 100644 index 0000000..0c7fcc6 --- /dev/null +++ b/TESTE_ABACATEPAY.md @@ -0,0 +1,162 @@ +# Guia de Teste - Sistema de Doações AbacatePay + +## 🔧 Configuração Inicial + +### 1. Criar conta no AbacatePay +- Acesse: https://abacatepay.com +- Crie uma conta gratuita +- Acesse o Dashboard + +### 2. Obter chave de API +- No Dashboard, vá para "API Keys" ou "Configurações" +- Copie sua **TEST API KEY** (começa com `abc_test_...`) +- Você também precisará da **PRODUCTION API KEY** quando for para produção + +### 3. Configurar produtos (OPCIONAL) +O sistema de PIX não requer produtos pré-cadastrados no AbacatePay. Os valores são enviados diretamente na requisição. + +Os produtos já estão definidos no código: +- Café: R$ 5,00 (500 centavos) +- Lanche: R$ 10,00 (1000 centavos) +- Almoço: R$ 25,00 (2500 centavos) + +### 4. Configurar variáveis de ambiente + +Crie um arquivo `.env.local` na raiz do projeto: + +```env +# AbacatePay API Keys +ABACATE_TEST_APIKEY=sua_chave_de_teste_aqui + +# URL base da aplicação +NEXT_PUBLIC_BASE_URL=http://localhost:3000 +``` + +**IMPORTANTE**: Não é necessário `ABACATEPAY_ACCOUNT_ID` nem produtos pré-cadastrados. O sistema PIX funciona diretamente com a API. + +## 🧪 Testando o Sistema + +### Ambiente de Teste (Development) + +1. **Inicie o servidor de desenvolvimento**: +```bash +pnpm run dev +``` + +2. **Acesse a página de doações**: +``` +http://localhost:3000/pt/apoiar +``` + +3. **Clique em um dos cards de doação** + +4. **Verifique os logs no terminal**: +``` +🔧 AbacatePay: Using TEST environment +🔍 AbacatePay Request: { + "frequency": "ONE_TIME", + "methods": ["PIX"], + "products": [ + { + "externalId": "prod_...", + "name": "Doação Café", + "quantity": 1, + "price": 500 + } + ], + ... +} +🔍 AbacatePay Response Status: 200 +``` + +5. **Resultado esperado**: +- Status 200 ✅ +- Redirecionamento para página de pagamento do AbacatePay +- OU exibição de QR Code PIX (modal) + +### Estrutura da Requisição Correta + +```json +{ + "frequency": "ONE_TIME", + "methods": ["PIX"], + "products": [ + { + "externalId": "prod_6ryJmhfZGNJGjNaYaA1YqhDn", + "name": "Doação Café", + "quantity": 1, + "price": 500 + } + ], + "returnUrl": "http://localhost:3000/apoiar/obrigado", + "completionUrl": "http://localhost:3000/api/donations/verify" +} +``` + +**Campos obrigatórios:** +- ✅ `externalId`: ID do produto pré-cadastrado +- ✅ `name`: Nome do produto +- ✅ `quantity`: Quantidade (sempre 1) +- ✅ `price`: Valor em centavos (R$ 5,00 = 500) + +## 🔍 Troubleshooting + +### Erro: "Route POST:/v1/billing not found" +**Solução**: Endpoint correto é `/billing/create`, não `/billing` + +### Erro: "must have required property 'name'" +**Solução**: Adicionar campo `name` no array de produtos + +### Erro: "Product not found" +**Solução**: Verificar se os IDs dos produtos estão corretos e existem no painel + +### Erro: "Invalid API key" +**Solução**: +- Verifique se a chave está correta no `.env.local` +- Certifique-se de usar `ABACATE_TEST_APIKEY` em development +- Reinicie o servidor após alterar variáveis de ambiente + +### Erro: "401 Unauthorized" +**Solução**: Chave de API expirada ou inválida. Gere uma nova no painel. + +## 📋 Checklist de Validação + +- [ ] Conta criada no AbacatePay +- [ ] API Key de teste obtida +- [ ] 3 produtos criados no painel (R$ 5, R$ 10, R$ 25) +- [ ] IDs dos produtos atualizados em `types/donation.ts` +- [ ] Arquivo `.env.local` criado com as variáveis +- [ ] Servidor reiniciado após configurar variáveis +- [ ] Página `/pt/apoiar` carrega sem erros +- [ ] Logs mostram "Using TEST environment" +- [ ] Clique em um card envia requisição +- [ ] Status 200 retornado +- [ ] Redirecionamento funciona ou QR Code é exibido + +## 🚀 Deploy em Produção + +Quando estiver pronto para produção: + +1. Configure as variáveis no Vercel/Netlify: +``` +ABACATE_APIKEY=abc_live_... +ABACATEPAY_ACCOUNT_ID=... +NEXT_PUBLIC_BASE_URL=https://seusite.com +``` + +2. O sistema detecta automaticamente o ambiente: +```typescript +const isProduction = process.env.NODE_ENV === "production"; +const apiKey = isProduction + ? process.env.ABACATE_APIKEY + : process.env.ABACATE_TEST_APIKEY; +``` + +3. Teste novamente em produção antes de divulgar + +## 📞 Suporte + +- Documentação: https://docs.abacatepay.com +- API Reference: https://abacatepay.readme.io +- Dashboard: https://abacatepay.com/dashboard + diff --git a/app/[locale]/apoiar/obrigado/page.tsx b/app/[locale]/apoiar/obrigado/page.tsx new file mode 100644 index 0000000..f4975c0 --- /dev/null +++ b/app/[locale]/apoiar/obrigado/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect } from "react"; +import { useParams } from "next/navigation"; +import confetti from "canvas-confetti"; +import Header from "@/components/Header"; +import { Button } from "@/components/ui/button"; +import { BlurFade } from "@/components/ui/blur-fade"; +import { Heart, Share2, Home } from "lucide-react"; +import Link from "next/link"; + +export default function ThankYouPage() { + const params = useParams(); + const locale = params.locale as string; + + // Confetti ao carregar página + useEffect(() => { + confetti({ + particleCount: 100, + spread: 70, + origin: { y: 0.6 }, + }); + }, []); + + return ( +
+
+ +
+ +
+ {/* Ícone de sucesso animado */} +
+
+
+
+ +
+
+
+ + {/* Título e subtítulo */} +

+ Muito Obrigado! 🎉 +

+

+ Sua doação foi confirmada com sucesso! +

+ + {/* Mensagem de agradecimento */} +
+

+ Sua generosidade nos ajuda a continuar desenvolvendo e mantendo + o Tibia Trade Pagodera. Cada contribuição faz toda a diferença + para nossa comunidade! +

+
+ + {/* Botões de ação */} +
+ + + + + + + + + +
+ + {/* Informação adicional */} +
+

+ 💡 Dica: Compartilhe nossa plataforma com seus + amigos! Quanto mais pessoas usarem o Tibia Trade Pagodera, mais + forte nossa comunidade fica. +

+
+
+ +
+
+ ); +} diff --git a/app/[locale]/apoiar/page.tsx b/app/[locale]/apoiar/page.tsx new file mode 100644 index 0000000..9d846c2 --- /dev/null +++ b/app/[locale]/apoiar/page.tsx @@ -0,0 +1,250 @@ +import Header from "@/components/Header"; +import { DonationCard } from "@/components/donation-card"; +import { BlurFade } from "@/components/ui/blur-fade"; +import { NumberTicker } from "@/components/ui/number-ticker"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Heart } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { ShareButton } from "@/components/share-button"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "donation" }); + return { + title: t("title"), + description: t("subtitle"), + }; +} + +export default async function SupportPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "donation" }); + + // Dados REAIS para demonstração + const stats = { + monthsOnline: 1, + uptime: 100, + }; + + // Custos mensais REAIS + const monthlyCosts = { + vps: 69, // R$ 69/mês + devHours: 40, // média horas/mês + devRate: 50, // R$ 50/hora + total: 2069, // R$ 69 + (40 * 50) + }; + + const faqItems = [ + { + question: t("faq.q1"), + answer: t("faq.a1"), + }, + { + question: t("faq.q2"), + answer: t("faq.a2"), + }, + { + question: t("faq.q3"), + answer: t("faq.a3"), + }, + { + question: t("faq.q4"), + answer: t("faq.a4"), + }, + { + question: t("faq.q5"), + answer: t("faq.a5"), + }, + ]; + + return ( +
+
+ +
+ {/* Hero Section */} + +
+
+
+ +
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+
+ + {/* História & Missão */} + +
+

+ {t("story.title")} +

+
+
+

+ {t("story.text")} +

+
+
+
+
+ + {/* Custos Reais */} + +
+

+ {t("costs.title")} +

+
+
+ {t("costs.vps")} + + {t("costs.vpsValue")} + +
+
+ {t("costs.dev")} + + {t("costs.devValue")} + +
+
+ {t("costs.total")} + + {t("costs.totalValue")} + +
+
+
+
+ + {/* Estatísticas Simplificadas */} + +
+
+
+

+ +

+

+ {t("social.months")} +

+
+
+

+ % +

+

+ {t("social.uptime")} +

+
+
+
+
+ + {/* Opções de Doação */} + +
+

+ {t("cta.support")} +

+
+ + + + +
+
+
+ + {/* FAQ */} + +
+

+ {t("faq.title")} +

+ + {faqItems.map((item, idx) => ( + + + {item.question} + + + {item.answer} + + + ))} + +
+
+ + {/* CTA Final */} + +
+
+

{t("cta.final")}

+

+ {t("cta.subtitle")} +

+
+ + +
+
+
+
+
+
+ ); +} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 7826884..a275135 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -6,6 +6,7 @@ import { HowItWorks } from "@/components/sections/how-it-works"; import { FeaturedItems } from "@/components/sections/featured-items"; import { MarketOffers } from "@/components/sections/market-offers"; import { MarketStats } from "@/components/sections/market-stats"; +import { DonationFooterCard } from "@/components/donation-footer-card"; export default function Home() { return ( @@ -20,6 +21,13 @@ export default function Home() { + + {/* Card de Doação no Footer */} +
+
+ +
+
); } diff --git a/app/api/donations/create/route.ts b/app/api/donations/create/route.ts new file mode 100644 index 0000000..3f74fad --- /dev/null +++ b/app/api/donations/create/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from "next/server"; +import { abacatePay } from "@/lib/abacatepay"; +import { DONATION_PRODUCTS, DonorData } from "@/types/donation"; +import { createDonationSchema } from "@/lib/validation-schemas"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; +import { secureLogger } from "@/lib/secure-logger"; + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/donations/create"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + + const body = await request.json(); + + // Validação com Zod + const validationResult = createDonationSchema.safeParse(body); + + if (!validationResult.success) { + const errors = validationResult.error.issues.map((err: any) => ({ + field: err.path.join("."), + message: err.message, + })); + + return NextResponse.json( + { + success: false, + error: "Dados de entrada inválidos", + details: errors, + }, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + const product = + DONATION_PRODUCTS[validatedData.tier as keyof typeof DONATION_PRODUCTS]; + + // Calcular valor final + let finalAmount: number; + let finalDescription: string; + + if (validatedData.tier === "custom") { + // Converter para centavos + finalAmount = Math.round(validatedData.customAmount! * 100); + finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount!.toFixed( + 2 + )}`; + } else { + finalAmount = product.price; + finalDescription = product.name; + } + + let pixData; + + // Fluxo: Anônimo ou Identificado + if (validatedData.anonymous === true) { + // Doação anônima - sem customer + pixData = await abacatePay.createPixQrCode({ + amount: finalAmount, + description: finalDescription, + expiresIn: 600, + }); + + secureLogger.info("PIX criado (anônimo)", { + pixId: pixData.id, + amount: finalAmount, + tier: validatedData.tier, + }); + } else { + // Doação identificada - dados já validados pelo Zod + pixData = await abacatePay.createPixQrCode({ + amount: finalAmount, + description: finalDescription, + expiresIn: 600, + customer: { + name: validatedData.donorName, + email: validatedData.donorEmail, + cellphone: validatedData.donorPhone, + taxId: validatedData.donorTaxId, + }, + }); + + secureLogger.info("PIX criado (identificado)", { + pixId: pixData.id, + amount: finalAmount, + tier: validatedData.tier, + donor: { + name: validatedData.donorName, + email: validatedData.donorEmail, + phone: validatedData.donorPhone, + taxId: validatedData.donorTaxId, + }, + }); + } + + return NextResponse.json({ + success: true, + pixId: pixData.id, + brCode: pixData.brCode, + brCodeBase64: pixData.brCodeBase64, + expiresAt: pixData.expiresAt, + }); + } catch (error) { + secureLogger.error("Donation creation error", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Erro interno", + }, + { status: 500 } + ); + } +} diff --git a/app/api/donations/status/route.ts b/app/api/donations/status/route.ts new file mode 100644 index 0000000..fdf9e8a --- /dev/null +++ b/app/api/donations/status/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPaymentStatus } from "@/lib/payment-cache"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; + +export async function GET(request: NextRequest) { + try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/donations/status"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + const { searchParams } = new URL(request.url); + const pixId = searchParams.get("pixId"); + + if (!pixId) { + return NextResponse.json( + { error: "pixId é obrigatório" }, + { status: 400 } + ); + } + + const status = getPaymentStatus(pixId); + + return NextResponse.json({ + pixId, + paid: status?.paid || false, + paidAt: status?.paidAt, + amount: status?.amount, + anonymous: status?.anonymous, + }); + } catch (error) { + console.error("Erro ao verificar status do pagamento:", error); + return NextResponse.json( + { error: "Erro ao verificar status" }, + { status: 500 } + ); + } +} diff --git a/app/api/donations/verify/route.ts b/app/api/donations/verify/route.ts new file mode 100644 index 0000000..8be92e3 --- /dev/null +++ b/app/api/donations/verify/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { abacatePay } from "@/lib/abacatepay"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; + +export async function GET(request: NextRequest) { + try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/donations/verify"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + const { searchParams } = new URL(request.url); + const billingId = searchParams.get("billingId"); + + if (!billingId) { + return NextResponse.json( + { success: false, error: "ID da cobrança é obrigatório" }, + { status: 400 } + ); + } + + const billing = await abacatePay.getBillingStatus(billingId); + + return NextResponse.json({ + success: true, + status: billing.status, + amount: billing.amount, + createdAt: billing.createdAt, + expiresAt: billing.expiresAt, + }); + } catch (error) { + console.error("Billing verification error:", error); + return NextResponse.json( + { + success: false, + error: + error instanceof Error ? error.message : "Erro ao verificar cobrança", + }, + { status: 500 } + ); + } +} diff --git a/app/api/webhooks/abacatepay/route.ts b/app/api/webhooks/abacatepay/route.ts new file mode 100644 index 0000000..b718266 --- /dev/null +++ b/app/api/webhooks/abacatepay/route.ts @@ -0,0 +1,156 @@ +import { NextRequest, NextResponse } from "next/server"; +import { abacatePay } from "@/lib/abacatepay"; +import { markAsPaid } from "@/lib/payment-cache"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; +import { secureLogger } from "@/lib/secure-logger"; + +// Whitelist de IPs do AbacatePay (configurável via env) +const ABACATEPAY_IPS = process.env.ABACATEPAY_WHITELIST_IPS?.split(",") || []; + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/webhooks/abacatepay"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + + // Validar IP de origem (se configurado) + if (ABACATEPAY_IPS.length > 0 && !ABACATEPAY_IPS.includes(ip)) { + secureLogger.warn("Webhook rejeitado: IP não autorizado", { ip }); + return NextResponse.json({ error: "IP não autorizado" }, { status: 403 }); + } + + const payload = await request.text(); + + // AbacatePay envia webhookSecret como query param + const { searchParams } = new URL(request.url); + const webhookSecret = searchParams.get("webhookSecret"); + const expectedSecret = process.env.ABACATEPAY_WEBHOOK_SECRET; + + // Validar secret + if (!webhookSecret || !expectedSecret || webhookSecret !== expectedSecret) { + secureLogger.warn("Webhook rejeitado: secret inválido ou ausente"); + return NextResponse.json({ error: "Secret inválido" }, { status: 401 }); + } + + secureLogger.info("Webhook autenticado com sucesso"); + + const event = JSON.parse(payload); + + secureLogger.debug("Webhook recebido", event); + + // Validar timestamp do evento (prevenir replay attacks) + const eventTimestamp = event.timestamp || event.data?.pixQrCode?.createdAt; + if (eventTimestamp) { + const eventTime = new Date(eventTimestamp).getTime(); + const now = Date.now(); + const timeDiff = Math.abs(now - eventTime); + + // Rejeitar eventos com mais de 5 minutos de diferença + if (timeDiff > 5 * 60 * 1000) { + secureLogger.warn("Webhook rejeitado: timestamp inválido", { + timeDiff: timeDiff / 1000 + "s", + }); + return NextResponse.json( + { error: "Timestamp inválido" }, + { status: 400 } + ); + } + } + + // Verificar se é evento de pagamento confirmado + const isPaidEvent = + event.event === "billing.paid" || + event.event === "pix.paid" || + event.data?.pixQrCode?.status === "PAID"; + + if (isPaidEvent) { + // Extrair dados do PIX + const pixQrCode = event.data.pixQrCode; + const payment = event.data.payment; + + if (!pixQrCode || pixQrCode.status !== "PAID") { + secureLogger.info("Webhook recebido mas status não é PAID"); + return NextResponse.json({ success: true }); + } + + secureLogger.info("Doação confirmada via webhook", { + pixId: pixQrCode.id, + amount: pixQrCode.amount, + }); + + // Detectar se é doação anônima ou identificada + const isAnonymous = !pixQrCode.customer; + + // Log estruturado para futura persistência em banco de dados + const donationRecord: any = { + type: "donation_paid", + pixId: pixQrCode.id, + amount: pixQrCode.amount, + fee: payment?.fee, + method: payment?.method, + anonymous: isAnonymous, + paidAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + devMode: event.devMode || false, + }; + + // Adicionar dados do customer se disponível (doação identificada) + if (!isAnonymous && pixQrCode.customer?.metadata) { + donationRecord.customer = { + id: pixQrCode.customer.id, + name: pixQrCode.customer.metadata.name, + email: pixQrCode.customer.metadata.email, + cellphone: pixQrCode.customer.metadata.cellphone, + taxId: pixQrCode.customer.metadata.taxId, + }; + + secureLogger.info("Email disponível para agradecimento", { + maskedEmail: pixQrCode.customer.metadata.email, + }); + } + + secureLogger.info( + "Registro de pagamento criado", + secureLogger.mask(donationRecord) + ); + + // Salvar no cache para frontend detectar via polling + markAsPaid(pixQrCode.id, donationRecord); + + // TODO: Quando implementar banco de dados, salvar aqui: + // await db.donations.create(donationRecord); + + // TODO: Quando implementar email, enviar aqui: + // if (!isAnonymous && donationRecord.customer?.email) { + // await sendThankYouEmail({ + // to: donationRecord.customer.email, + // donorName: donationRecord.customer.name, + // amount: donationRecord.amount, + // }); + // } + + // TODO: Atualizar estatísticas + // await updateDonationStats(donationRecord.amount); + } else { + secureLogger.info("Evento de webhook (não é pagamento)", { + event: event.event, + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + secureLogger.error("Webhook error", error); + return NextResponse.json( + { error: "Erro ao processar webhook" }, + { status: 500 } + ); + } +} diff --git a/components/Header.tsx b/components/Header.tsx index cd12236..5f7b2f2 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -23,6 +23,7 @@ export default function Header() { { href: `/${locale}/market`, label: t("market") }, { href: `/${locale}/sobre`, label: t("about") }, { href: `/${locale}/precos`, label: t("prices") }, + { href: `/${locale}/apoiar`, label: t("support"), highlight: true }, ]; return ( @@ -41,14 +42,16 @@ export default function Header() { {/* Desktop Navigation */} - {links.map(({ href, label }) => ( + {links.map(({ href, label, highlight }) => ( {label} diff --git a/components/donation-card.tsx b/components/donation-card.tsx new file mode 100644 index 0000000..dbcaa5f --- /dev/null +++ b/components/donation-card.tsx @@ -0,0 +1,865 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import confetti from "canvas-confetti"; +import { + Heart, + Coffee, + Pizza, + Loader2, + Copy, + Check, + Lock, + User, + CheckCircle, + CheckCircle2, + DollarSign, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DonationTier, DonationResponse } from "@/types/donation"; +import { useTranslations } from "next-intl"; + +interface DonationCardProps extends DonationTier {} + +export function DonationCard({ + id, + amount, + title, + description, + icon, + badge, + highlighted, +}: DonationCardProps) { + // 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(""); + const [customAmount, setCustomAmount] = useState(""); + + // Estados para PIX + const [loading, setLoading] = useState(false); + const [showPixModal, setShowPixModal] = useState(false); + const [pixData, setPixData] = useState<{ + pixId: string; + brCode: string; + brCodeBase64: string; + expiresAt?: string; + } | null>(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [timeRemaining, setTimeRemaining] = useState(null); + const [paymentConfirmed, setPaymentConfirmed] = useState(false); + const [showThankYouModal, setShowThankYouModal] = useState(false); + + const t = useTranslations("donation"); + + const icons = { + coffee: Coffee, + pizza: Pizza, + heart: Heart, + }; + + const Icon = icons[icon]; + + // Confetti ao abrir modal de agradecimento + useEffect(() => { + if (showThankYouModal) { + confetti({ + particleCount: 100, + spread: 70, + origin: { y: 0.6 }, + }); + } + }, [showThankYouModal]); + + // 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); + } + }; + + // Verificar pagamento via API + const checkPaymentConfirmation = async () => { + if (!pixData?.pixId) return; + + try { + const response = await fetch( + `/api/donations/status?pixId=${pixData.pixId}` + ); + const data = await response.json(); + + if (data.paid) { + console.log("✅ Pagamento confirmado!"); + setPaymentConfirmed(true); + + // Confetti celebration! + confetti({ + particleCount: 100, + spread: 70, + origin: { y: 0.6 }, + }); + + setTimeout(() => { + setShowPixModal(false); + setShowThankYouModal(true); + // Limpar localStorage + localStorage.removeItem("currentPixId"); + }, 1000); + clearInterval(timerInterval); + } + } catch (error) { + console.error("Erro ao verificar pagamento:", error); + } + }; + + // Atualizar timer imediatamente + updateTimer(); + + // Atualizar timer e verificar pagamento a cada 2 segundos + const timerInterval = setInterval(() => { + updateTimer(); + checkPaymentConfirmation(); + }, 2000); + + 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 requestBody: any = { + tier: id, + anonymous: true, + }; + + // Adicionar valor customizado se for custom + if (id === "custom" && customAmount) { + requestBody.customAmount = parseFloat(customAmount); + } + + const response = await fetch("/api/donations/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }); + + 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, + }); + // Salvar pixId no localStorage para verificação + localStorage.setItem("currentPixId", data.pixId); + setShowPixModal(true); + } else { + setError(data.error || "Erro ao processar doação"); + setShowChoiceModal(true); + } + } catch (error) { + console.error("Donation error:", error); + 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 requestBody: any = { + 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, + }; + + // Adicionar valor customizado se for custom + if (id === "custom" && customAmount) { + requestBody.customAmount = parseFloat(customAmount); + } + + const response = await fetch("/api/donations/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }); + + 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, + }); + // Salvar pixId no localStorage + localStorage.setItem("currentPixId", data.pixId); + 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?.brCode) { + try { + await navigator.clipboard.writeText(pixData.brCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Erro ao copiar código PIX:", error); + } + } + }; + + // Funções para doação customizada + const { DONATION_PRODUCTS } = require("@/types/donation"); + const customProduct = DONATION_PRODUCTS.custom; + + const isValidCustomAmount = () => { + const amount = parseFloat(customAmount); + return ( + !isNaN(amount) && + amount >= customProduct.minAmount && + amount <= customProduct.maxAmount + ); + }; + + const formatCurrency = (value: string): string => { + // Permite vírgula durante digitação, mas converte para ponto + // Remove tudo exceto números, vírgulas e pontos + const cleaned = value.replace(/[^\d.,]/g, ""); + + // Se tem vírgula, troca por ponto + const normalized = cleaned.replace(/,/g, "."); + + // Garante apenas um separador decimal + const parts = normalized.split("."); + if (parts.length > 2) { + return parts[0] + "." + parts.slice(1).join(""); + } + + // Limita 2 casas decimais + if (parts[1]?.length > 2) { + return parts[0] + "." + parts[1].substring(0, 2); + } + + return normalized; + }; + + const handleCustomAmountChange = (e: React.ChangeEvent) => { + const formatted = formatCurrency(e.target.value); + + // Se o usuário digitou vírgula e ainda não há ponto, adiciona vírgula temporariamente + const rawValue = e.target.value; + if ( + rawValue.includes(",") && + !rawValue.includes(".") && + !rawValue.replace(/[^\d,]/g, "").includes(".") + ) { + // Permite a vírgula momentaneamente, mas converte ao processar + setCustomAmount(formatted); + } else { + setCustomAmount(formatted); + } + }; + + const handleCustomDonationClick = () => { + if (!customAmount) { + setError("Digite um valor"); + return; + } + const amount = parseFloat(customAmount); + if (!isValidCustomAmount()) { + setError( + `Valor deve estar entre R$ ${customProduct.minAmount.toFixed( + 2 + )} e R$ ${customProduct.maxAmount.toFixed(2)}` + ); + return; + } + handleDonateClick(); + }; + + return ( + <> +
+ {badge && ( + + {badge} + + )} + +
+
+ +
+ +

{title}

+

{description}

+ + {id === "custom" ? ( + <> +
+
+ + +

+ {t("form.min")}: R$ {customProduct.minAmount.toFixed(2)} |{" "} + {t("form.max")}: R$ {customProduct.maxAmount.toFixed(2)} +

+
+ {customAmount && !isValidCustomAmount() && ( +

+ {parseFloat(customAmount) < customProduct.minAmount + ? `${t( + "form.minAmount" + )} ${customProduct.minAmount.toFixed(2)}` + : `${t( + "form.maxAmount" + )} ${customProduct.maxAmount.toFixed(2)}`} +

+ )} +
+ + + + ) : ( + <> + {amount && ( +
+ R$ {amount.toFixed(2)} +
+ )} + + + + )} +
+
+ + {/* Modal de Escolha (Anônimo vs Identificado) */} + + + + + {t("form.howToContribute")} + + + +
+ {/* Card Anônimo */} +
+
+
+ +
+

+ {t("form.anonymousTitle")} +

+
+

+ {t("form.anonymousDesc")} +

+ +
+ + {/* Card Identificado */} +
+ + {t("form.recommended")} + +
+
+ +
+

+ {t("form.identifiedTitle")} +

+
+

+ {t("form.identifiedDesc")} +

+ +
+
+ + {t("form.thankByEmail")} +
+
+ + {t("form.earlyAccess")} +
+
+ + {t("form.contributionHistory")} +
+
+ + +
+
+
+
+ + {/* Modal de Formulário do Doador Identificado */} + + + + {t("form.donorInfo")} + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + setDonorName(e.target.value)} + placeholder={t("form.namePlaceholder")} + maxLength={50} + /> +
+ +
+ + setDonorEmail(e.target.value)} + placeholder={t("form.emailPlaceholder")} + /> +
+ +
+ + setDonorPhone(formatPhone(e.target.value))} + placeholder={t("form.phonePlaceholder")} + maxLength={15} + /> +
+ +
+ + { + const value = e.target.value.replace(/\D/g, ""); + if (value.length <= 11) { + setDonorTaxId(formatCPF(value)); + } else { + setDonorTaxId(formatCNPJ(value)); + } + }} + placeholder={t("form.taxIdPlaceholder")} + maxLength={18} + /> +
+ +
+ +