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 */}
+
+
+
+
+ Voltar ao Início
+
+
+
+
+
+
+ Fazer Outra Doação
+
+
+
+ {
+ if (typeof navigator !== "undefined" && navigator.share) {
+ navigator.share({
+ title: "Tibia Trade Pagodera",
+ text: "Acabei de fazer uma doação para o Tibia Trade Pagodera! Ajude também!",
+ url: window.location.origin,
+ });
+ } else if (
+ typeof navigator !== "undefined" &&
+ navigator.clipboard
+ ) {
+ navigator.clipboard.writeText(window.location.origin);
+ alert("Link copiado para a área de transferência!");
+ }
+ }}
+ >
+
+ Compartilhar
+
+
+
+ {/* 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")}
+
+
+
+ {t("cta.support")}
+
+
+
+
+
+
+
+
+ );
+}
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.value")}
+
+
+
+ {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)}`}
+
+ )}
+
+
+
+ {loading ? (
+ <>
+
+ {t("form.processing")}
+ >
+ ) : customAmount ? (
+ `${t("form.donate")} ${parseFloat(
+ customAmount || "0"
+ ).toFixed(2)}`
+ ) : (
+ t("form.chooseAmount")
+ )}
+
+ >
+ ) : (
+ <>
+ {amount && (
+
+ R$ {amount.toFixed(2)}
+
+ )}
+
+
+ {loading ? (
+ <>
+
+ {t("form.processing")}
+ >
+ ) : (
+ t("form.supportWithPix")
+ )}
+
+ >
+ )}
+
+
+
+ {/* Modal de Escolha (Anônimo vs Identificado) */}
+
+
+
+
+ {t("form.howToContribute")}
+
+
+
+
+ {/* Card Anônimo */}
+
+
+
+
+
+
+ {t("form.anonymousTitle")}
+
+
+
+ {t("form.anonymousDesc")}
+
+
+ {t("form.anonymousButton")}
+
+
+
+ {/* Card Identificado */}
+
+
+ {t("form.recommended")}
+
+
+
+
+
+
+ {t("form.identifiedTitle")}
+
+
+
+ {t("form.identifiedDesc")}
+
+
+
+
+
+ {t("form.thankByEmail")}
+
+
+
+ {t("form.earlyAccess")}
+
+
+
+ {t("form.contributionHistory")}
+
+
+
+
+ {t("form.identifyMe")}
+
+
+
+
+
+
+ {/* Modal de Formulário do Doador Identificado */}
+
+
+
+ {t("form.donorInfo")}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {t("form.name")}{" "}
+
+ ({t("form.nameHint")})
+
+
+ setDonorName(e.target.value)}
+ placeholder={t("form.namePlaceholder")}
+ maxLength={50}
+ />
+
+
+
+
+ {t("form.email")}
+
+ setDonorEmail(e.target.value)}
+ placeholder={t("form.emailPlaceholder")}
+ />
+
+
+
+
+ {t("form.phone")}{" "}
+
+ ({t("form.phoneHint")})
+
+
+ setDonorPhone(formatPhone(e.target.value))}
+ placeholder={t("form.phonePlaceholder")}
+ maxLength={15}
+ />
+
+
+
+
+ {t("form.taxId")}{" "}
+
+ ({t("form.taxIdHint")})
+
+
+ {
+ 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}
+ />
+
+
+
+
+ {t("form.message")}{" "}
+
+ ({t("form.messageHint")})
+
+
+
+
+
+ {
+ setShowDonorForm(false);
+ setShowChoiceModal(true);
+ setError(null);
+ }}
+ variant="outline"
+ className="flex-1"
+ disabled={loading}
+ >
+ {t("form.close")}
+
+
+ {loading ? (
+ <>
+
+ {t("form.processing")}
+ >
+ ) : (
+ t("form.generatePix")
+ )}
+
+
+
+
+
+
+ {/* Modal de QR Code PIX */}
+
+
+
+
+ {t("form.qrTitle")}
+
+
+
+ {pixData && (
+
+
+
+
+
+
+
+ {t("form.copyCode")}
+
+
+
+
+ {copied ? (
+ <>
+
+ {t("form.copied")}
+ >
+ ) : (
+ <>
+
+ {t("form.copy")}
+ >
+ )}
+
+
+
+
+ {timeRemaining && (
+
+ ⏱️ {t("form.remainingTime")} {timeRemaining}
+
+ )}
+
+
+ setShowPixModal(false)}
+ variant="outline"
+ className="flex-1"
+ >
+ {t("form.close")}
+
+ (window.location.href = "/apoiar/obrigado")}
+ className="flex-1"
+ >
+ {t("form.paid")}
+
+
+
+ )}
+
+
+
+ {/* Modal de Agradecimento */}
+
+
+
+ {/* Ícone de sucesso animado */}
+
+
+ {/* Título e mensagem */}
+
+
+ {t("form.paymentConfirmed")} 🎉
+
+
+ {t("form.paymentConfirmedDesc")}
+
+
+
+ {/* Mensagem especial */}
+
+
+ {t("form.contributionMatters")} ❤️
+
+
+
+ {/* Botões */}
+
+ {
+ setShowThankYouModal(false);
+ window.location.href = "/apoiar/obrigado";
+ }}
+ className="flex-1"
+ size="lg"
+ >
+ {t("form.viewThankYouPage")}
+
+ {
+ setShowThankYouModal(false);
+ window.location.href = "/";
+ }}
+ variant="outline"
+ className="flex-1"
+ size="lg"
+ >
+ {t("form.backToHome")}
+
+
+
+
+
+ >
+ );
+}
diff --git a/components/donation-footer-card.tsx b/components/donation-footer-card.tsx
new file mode 100644
index 0000000..88c2c9e
--- /dev/null
+++ b/components/donation-footer-card.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Heart, Coffee } from "lucide-react";
+import { BlurFade } from "@/components/ui/blur-fade";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+
+export function DonationFooterCard() {
+ const t = useTranslations("donation");
+
+ return (
+
+
+
+
+
+
+
+
+
{t("title")}
+
{t("subtitle")}
+
+
+
+
+
+ Apoiar
+
+
+
+
+
+ );
+}
diff --git a/components/share-button.tsx b/components/share-button.tsx
new file mode 100644
index 0000000..ec2c8a2
--- /dev/null
+++ b/components/share-button.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { Share2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export function ShareButton() {
+ const handleShare = () => {
+ if (navigator.share) {
+ navigator.share({
+ title: "Tibia Trade Pagodera",
+ text: "Conheça o marketplace mais seguro para negociar itens no servidor Pagodera!",
+ url: window.location.origin,
+ });
+ } else {
+ navigator.clipboard.writeText(window.location.origin);
+ alert("Link copiado para a área de transferência!");
+ }
+ };
+
+ return (
+
+
+ Compartilhar Projeto
+
+ );
+}
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..5e77d51
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,23 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/lib/abacatepay.ts b/lib/abacatepay.ts
new file mode 100644
index 0000000..73ee53f
--- /dev/null
+++ b/lib/abacatepay.ts
@@ -0,0 +1,195 @@
+interface AbacatePayConfig {
+ apiKey: string;
+ baseUrl?: string;
+}
+
+interface CreateBillingParams {
+ amount: number;
+ metadata?: {
+ donorName?: string;
+ donorEmail?: string;
+ message?: string;
+ };
+ returnUrl?: string;
+ completionUrl?: string;
+}
+
+interface BillingResponse {
+ id: string;
+ amount: number;
+ status: "PENDING" | "PAID" | "EXPIRED" | "CANCELLED";
+ brCode?: string; // Código PIX copia-e-cola
+ brCodeBase64?: string; // QR Code em Base64
+ expiresAt?: string;
+ createdAt: string;
+}
+
+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 {
+ private config: AbacatePayConfig;
+
+ constructor(config: AbacatePayConfig) {
+ this.config = {
+ ...config,
+ baseUrl: config.baseUrl || "https://api.abacatepay.com/v1",
+ };
+ }
+
+ async createPixQrCode(params: CreatePixParams): Promise {
+ const requestBody: any = {
+ amount: params.amount,
+ expiresIn: params.expiresIn || 600, // 10 minutos por padrão
+ description: params.description,
+ };
+
+ if (params.customer) {
+ requestBody.customer = params.customer;
+ }
+
+ // Request body logging removido para segurança LGPD
+ // Dados sensíveis não devem aparecer em logs
+
+ 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),
+ });
+
+ 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();
+
+ if (result.error) {
+ throw new Error(result.error);
+ }
+
+ return result.data;
+ }
+
+ async createBilling(params: CreateBillingParams): Promise {
+ const response = await fetch(`${this.config.baseUrl}/billing`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${this.config.apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ amount: params.amount * 100, // centavos
+ metadata: params.metadata,
+ return_url: params.returnUrl,
+ completion_url: params.completionUrl,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(
+ `Failed to create billing: ${errorData.message || response.statusText}`
+ );
+ }
+
+ return response.json();
+ }
+
+ async getBillingStatus(billingId: string): Promise {
+ const response = await fetch(
+ `${this.config.baseUrl}/billing/${billingId}`,
+ {
+ headers: {
+ Authorization: `Bearer ${this.config.apiKey}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(
+ `Failed to get billing status: ${
+ errorData.message || response.statusText
+ }`
+ );
+ }
+
+ const result = await response.json();
+
+ // AbacatePay retorna { error: null, data: { ... } }
+ if (result.error) {
+ throw new Error(result.error);
+ }
+
+ // Retornar apenas o data
+ return result.data;
+ }
+
+ async getPixStatus(pixId: string): Promise {
+ // AbacatePay usa o endpoint /billing/{id} para todos os tipos de billing
+ const response = await fetch(`${this.config.baseUrl}/billing/${pixId}`, {
+ headers: {
+ Authorization: `Bearer ${this.config.apiKey}`,
+ },
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(
+ `Failed to get PIX status: ${errorData.message || response.statusText}`
+ );
+ }
+
+ const result = await response.json();
+
+ if (result.error) {
+ throw new Error(result.error);
+ }
+
+ return result.data;
+ }
+
+ verifyWebhookSignature(payload: string, signature: string): boolean {
+ // Método mantido para compatibilidade, mas validação agora é feita via query param
+ return true;
+ }
+}
+
+// Instância do cliente (será configurada com variáveis de ambiente)
+const isProduction = process.env.NODE_ENV === "production";
+const apiKey = isProduction
+ ? process.env.ABACATE_APIKEY || "demo_key"
+ : process.env.ABACATE_TEST_APIKEY || "demo_key";
+
+// Log das variáveis de ambiente (sem expor valores sensíveis)
+console.log("\n🔧 Configuração do AbacatePay:");
+console.log(`📊 Environment: ${isProduction ? "PRODUCTION" : "TEST"}`);
+console.log(`📊 API Key: ${apiKey ? "✅ Configurada" : "❌ NÃO CONFIGURADA"}`);
+console.log(
+ `📊 Webhook Secret: ${
+ process.env.ABACATEPAY_WEBHOOK_SECRET
+ ? "✅ Configurada"
+ : "❌ NÃO CONFIGURADA"
+ }`
+);
+console.log("🔧 ==========================================\n");
+
+export const abacatePay = new AbacatePayClient({
+ apiKey,
+});
+
+export type { CreateBillingParams, BillingResponse };
diff --git a/lib/payment-cache.ts b/lib/payment-cache.ts
new file mode 100644
index 0000000..b91eca7
--- /dev/null
+++ b/lib/payment-cache.ts
@@ -0,0 +1,47 @@
+// Cache simples em memória (Map) para armazenar status de pagamentos
+// Em produção, trocar por Redis ou banco de dados
+
+interface PaymentStatus {
+ paid: boolean;
+ paidAt?: string;
+ amount: number;
+ anonymous: boolean;
+ customer?: any;
+}
+
+const paymentCache = new Map();
+
+export function markAsPaid(
+ pixId: string,
+ data: {
+ paidAt: string;
+ amount: number;
+ anonymous: boolean;
+ customer?: any;
+ }
+) {
+ paymentCache.set(pixId, {
+ paid: true,
+ paidAt: data.paidAt,
+ amount: data.amount,
+ anonymous: data.anonymous,
+ customer: data.customer,
+ });
+
+ console.log(`✅ Pagamento marcado como pago no cache: ${pixId}`);
+
+ // Limpar após 1 hora
+ setTimeout(() => {
+ paymentCache.delete(pixId);
+ console.log(`🗑️ Cache expirado para: ${pixId}`);
+ }, 3600000);
+}
+
+export function getPaymentStatus(pixId: string): PaymentStatus | undefined {
+ return paymentCache.get(pixId);
+}
+
+// Limpar cache expirado periodicamente (a cada 5 minutos)
+setInterval(() => {
+ console.log(`📊 Cache atual: ${paymentCache.size} pagamentos armazenados`);
+}, 300000);
diff --git a/lib/payment-helper.ts b/lib/payment-helper.ts
new file mode 100644
index 0000000..da01618
--- /dev/null
+++ b/lib/payment-helper.ts
@@ -0,0 +1,42 @@
+// Helper para gerenciar confirmações de pagamento via localStorage
+// Este arquivo será usado pelo webhook para notificar o frontend
+
+export function confirmPayment(pixId: string) {
+ if (typeof window === "undefined") return;
+
+ try {
+ // Obter array de pixIds confirmados
+ const confirmedStorage = localStorage.getItem("confirmedPixIds");
+ let confirmed: string[] = confirmedStorage
+ ? JSON.parse(confirmedStorage)
+ : [];
+
+ // Adicionar novo pixId se ainda não estiver na lista
+ if (!confirmed.includes(pixId)) {
+ confirmed.push(pixId);
+ localStorage.setItem("confirmedPixIds", JSON.stringify(confirmed));
+ console.log("✅ Pagamento confirmado via helper:", pixId);
+ }
+ } catch (error) {
+ console.error("Erro ao confirmar pagamento:", error);
+ }
+}
+
+export function isPaymentConfirmed(pixId: string): boolean {
+ if (typeof window === "undefined") return false;
+
+ try {
+ const confirmedStorage = localStorage.getItem("confirmedPixIds");
+ const confirmed: string[] = confirmedStorage
+ ? JSON.parse(confirmedStorage)
+ : [];
+ return confirmed.includes(pixId);
+ } catch {
+ return false;
+ }
+}
+
+// Expor função global para o webhook (se necessário)
+if (typeof window !== "undefined") {
+ (window as any).confirmPaymentPix = confirmPayment;
+}
diff --git a/lib/rate-limiter.ts b/lib/rate-limiter.ts
new file mode 100644
index 0000000..edf1163
--- /dev/null
+++ b/lib/rate-limiter.ts
@@ -0,0 +1,169 @@
+/**
+ * Rate Limiter para ambiente serverless
+ * Utiliza Map em memória com TTL (Time To Live)
+ * Adequado para serverless de curta duração
+ */
+
+interface RateLimitConfig {
+ maxRequests: number;
+ windowMs: number;
+}
+
+interface RateLimitEntry {
+ count: number;
+ resetAt: number;
+}
+
+// Cache em memória de requisições por IP
+const requestCache = new Map();
+
+// Configuração de rate limits por endpoint
+const RATE_LIMITS: Record = {
+ "/api/donations/create": {
+ maxRequests: 5,
+ windowMs: 10 * 60 * 1000, // 10 minutos
+ },
+ "/api/donations/status": {
+ maxRequests: 30,
+ windowMs: 60 * 1000, // 1 minuto
+ },
+ "/api/donations/verify": {
+ maxRequests: 20,
+ windowMs: 60 * 1000, // 1 minuto
+ },
+ "/api/webhooks/abacatepay": {
+ maxRequests: 100,
+ windowMs: 60 * 1000, // 1 minuto
+ },
+};
+
+// Limpar cache expirado a cada 5 minutos
+setInterval(() => {
+ const now = Date.now();
+ let cleaned = 0;
+
+ for (const [key, entry] of requestCache.entries()) {
+ if (now > entry.resetAt) {
+ requestCache.delete(key);
+ cleaned++;
+ }
+ }
+
+ if (cleaned > 0) {
+ console.log(`🧹 Rate limiter: ${cleaned} entradas expiradas removidas`);
+ }
+}, 5 * 60 * 1000);
+
+/**
+ * Verifica se uma requisição excede o rate limit
+ * @param ip - Endereço IP do cliente
+ * @param path - Caminho da requisição (ex: /api/donations/create)
+ * @returns Objeto com informações sobre o rate limit
+ */
+export function checkRateLimit(
+ ip: string,
+ path: string
+): {
+ allowed: boolean;
+ remaining: number;
+ resetAt: number;
+ limit: number;
+} {
+ const config = RATE_LIMITS[path];
+
+ // Se não há config para este path, permite tudo
+ if (!config) {
+ return {
+ allowed: true,
+ remaining: Infinity,
+ resetAt: Date.now() + 60000,
+ limit: Infinity,
+ };
+ }
+
+ const key = `${ip}:${path}`;
+ const now = Date.now();
+ const entry = requestCache.get(key);
+
+ // Se não existe entrada ou expirou, criar nova
+ if (!entry || now > entry.resetAt) {
+ const newEntry: RateLimitEntry = {
+ count: 1,
+ resetAt: now + config.windowMs,
+ };
+ requestCache.set(key, newEntry);
+
+ return {
+ allowed: true,
+ remaining: config.maxRequests - 1,
+ resetAt: newEntry.resetAt,
+ limit: config.maxRequests,
+ };
+ }
+
+ // Se existe e não expirou, incrementar contador
+ entry.count++;
+
+ if (entry.count > config.maxRequests) {
+ return {
+ allowed: false,
+ remaining: 0,
+ resetAt: entry.resetAt,
+ limit: config.maxRequests,
+ };
+ }
+
+ return {
+ allowed: true,
+ remaining: config.maxRequests - entry.count,
+ resetAt: entry.resetAt,
+ limit: config.maxRequests,
+ };
+}
+
+/**
+ * Extrai o IP real de uma requisição
+ * Considera headers de proxies/CDN (x-forwarded-for, x-real-ip)
+ */
+export function getClientIP(request: Request): string {
+ // Tentar x-forwarded-for primeiro (pode ter múltiplos IPs)
+ const forwardedFor = request.headers.get("x-forwarded-for");
+ if (forwardedFor) {
+ // Pegar apenas o primeiro IP (cliente original)
+ return forwardedFor.split(",")[0].trim();
+ }
+
+ // Tentar x-real-ip
+ const realIP = request.headers.get("x-real-ip");
+ if (realIP) {
+ return realIP.trim();
+ }
+
+ // Fallback: usar conexão remota
+ return request.headers.get("cf-connecting-ip") || "unknown";
+}
+
+/**
+ * Middleware helper para criar resposta de rate limit
+ */
+export function createRateLimitResponse(resetAt: number): Response {
+ const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
+
+ return new Response(
+ JSON.stringify({
+ error: "Rate limit excedido",
+ message: "Muitas requisições. Tente novamente mais tarde.",
+ retryAfter,
+ }),
+ {
+ status: 429,
+ headers: {
+ "Content-Type": "application/json",
+ "Retry-After": retryAfter.toString(),
+ "X-RateLimit-Limit": "1",
+ "X-RateLimit-Remaining": "0",
+ "X-RateLimit-Reset": new Date(resetAt).toISOString(),
+ },
+ }
+ );
+}
diff --git a/lib/secure-logger.ts b/lib/secure-logger.ts
new file mode 100644
index 0000000..0ed0757
--- /dev/null
+++ b/lib/secure-logger.ts
@@ -0,0 +1,208 @@
+/**
+ * Logger Seguro - Conformidade LGPD
+ * Mascara automaticamente dados pessoais sensíveis em logs
+ * Previne vazamento de informações em ambientes de produção
+ */
+
+interface MaskOptions {
+ /**
+ * Mascara parcial de email (mantém parte visível)
+ * Ex: user@domain.com -> u***@domain.com
+ */
+ maskEmail?: boolean;
+
+ /**
+ * Mascara parcial de CPF/CNPJ
+ * Ex: 123.456.789-00 -> ***.***.***-00
+ */
+ maskDocument?: boolean;
+
+ /**
+ * Mascara telefone completo
+ * Ex: (11) 98765-4321 -> (11) *****-4321
+ */
+ maskPhone?: boolean;
+
+ /**
+ * Remove completamente dados sensíveis em produção
+ */
+ removeSensitive?: boolean;
+}
+
+const isProduction = process.env.NODE_ENV === "production";
+
+/**
+ * Mascara email mantendo primeiro caractere e domínio
+ */
+function maskEmail(email: string): string {
+ if (!email || !email.includes("@")) return email;
+
+ const [localPart, domain] = email.split("@");
+ const maskedLocal = localPart.substring(0, 1) + "***";
+
+ return `${maskedLocal}@${domain}`;
+}
+
+/**
+ * Mascara CPF mantendo apenas os dois últimos dígitos
+ */
+function maskCPF(cpf: string): string {
+ if (!cpf) return cpf;
+
+ // Remove formatação
+ const cleaned = cpf.replace(/[^\d]/g, "");
+
+ if (cleaned.length === 11) {
+ return `***.***.***-${cleaned.slice(-2)}`;
+ }
+
+ if (cleaned.length === 14) {
+ // CNPJ
+ return `**.***.***/****-${cleaned.slice(-2)}`;
+ }
+
+ return "***.***.***-**";
+}
+
+/**
+ * Mascara telefone mantendo DDD e últimos 4 dígitos
+ */
+function maskPhone(phone: string): string {
+ if (!phone) return phone;
+
+ // Remove formatação
+ const cleaned = phone.replace(/[^\d]/g, "");
+
+ if (cleaned.length >= 10) {
+ const ddd = cleaned.substring(0, 2);
+ const lastFour = cleaned.slice(-4);
+ return `(${ddd}) *****-${lastFour}`;
+ }
+
+ return "**-****-****";
+}
+
+/**
+ * Mascara objeto recursivamente
+ */
+function maskSensitiveData(obj: any, options: MaskOptions = {}): any {
+ if (!obj || typeof obj !== "object") {
+ return obj;
+ }
+
+ // Se for string, verifica se é dado sensível
+ if (typeof obj === "string") {
+ // Email
+ if (obj.includes("@") && options.maskEmail) {
+ return maskEmail(obj);
+ }
+
+ // CPF/CNPJ
+ const digitsOnly = obj.replace(/[^\d]/g, "");
+ if (
+ (digitsOnly.length === 11 || digitsOnly.length === 14) &&
+ options.maskDocument
+ ) {
+ return maskCPF(obj);
+ }
+
+ // Telefone
+ if (digitsOnly.length >= 10 && options.maskPhone) {
+ return maskPhone(obj);
+ }
+
+ return obj;
+ }
+
+ // Se for array
+ if (Array.isArray(obj)) {
+ return obj.map((item) => maskSensitiveData(item, options));
+ }
+
+ // Se for objeto
+ const masked: any = {};
+ for (const [key, value] of Object.entries(obj)) {
+ const lowerKey = key.toLowerCase();
+
+ // Identificar campos sensíveis
+ const isEmail = lowerKey.includes("email") || lowerKey === "mail";
+ const isDocument =
+ lowerKey.includes("cpf") ||
+ lowerKey.includes("cnpj") ||
+ lowerKey.includes("taxid");
+ const isPhone =
+ lowerKey.includes("phone") ||
+ lowerKey.includes("cellphone") ||
+ lowerKey.includes("telefone");
+
+ if (isEmail && options.maskEmail) {
+ masked[key] = maskEmail(String(value));
+ } else if (isDocument && options.maskDocument) {
+ masked[key] = maskCPF(String(value));
+ } else if (isPhone && options.maskPhone) {
+ masked[key] = maskPhone(String(value));
+ } else {
+ masked[key] = maskSensitiveData(value, options);
+ }
+ }
+
+ return masked;
+}
+
+/**
+ * Logger seguro que mascara dados sensíveis
+ */
+export const secureLogger = {
+ /**
+ * Log info com mascaramento automático
+ */
+ info(message: string, data?: any) {
+ if (isProduction && data) {
+ const masked = maskSensitiveData(data, {
+ maskEmail: true,
+ maskDocument: true,
+ maskPhone: true,
+ });
+ console.log(`ℹ️ ${message}`, JSON.stringify(masked, null, 2));
+ } else {
+ console.log(`ℹ️ ${message}`, data ? JSON.stringify(data, null, 2) : "");
+ }
+ },
+
+ /**
+ * Log debug com mascaramento automático
+ */
+ debug(message: string, data?: any) {
+ if (isProduction && data) {
+ const masked = maskSensitiveData(data, {
+ maskEmail: true,
+ maskDocument: true,
+ maskPhone: true,
+ });
+ console.debug(`🔍 ${message}`, JSON.stringify(masked, null, 2));
+ } else {
+ console.debug(`🔍 ${message}`, data ? JSON.stringify(data, null, 2) : "");
+ }
+ },
+
+ /**
+ * Log warning
+ */
+ warn(message: string, data?: any) {
+ console.warn(`⚠️ ${message}`, data || "");
+ },
+
+ /**
+ * Log error
+ */
+ error(message: string, error?: any) {
+ console.error(`❌ ${message}`, error || "");
+ },
+
+ /**
+ * Mascara dados manualmente
+ */
+ mask(data: any, options?: MaskOptions) {
+ return maskSensitiveData(data, options);
+ },
+};
diff --git a/lib/validation-schemas.ts b/lib/validation-schemas.ts
new file mode 100644
index 0000000..a9eac6d
--- /dev/null
+++ b/lib/validation-schemas.ts
@@ -0,0 +1,145 @@
+import { z } from "zod";
+import validator from "validator";
+import { isValidDocument } from "./validators/document-validator";
+import sanitizeHtml from "sanitize-html";
+
+/**
+ * Schemas de validação usando Zod
+ * Garante type-safety e validação robusta de dados de entrada
+ */
+
+// Schema para email - validação RFC 5322
+export const emailSchema = z
+ .string()
+ .min(1, "Email é obrigatório")
+ .email("Formato de email inválido")
+ .refine((email) => validator.isEmail(email), {
+ message: "Email inválido",
+ })
+ .transform((email) => email.trim().toLowerCase());
+
+// Schema para telefone brasileiro
+export const phoneSchema = z
+ .string()
+ .min(1, "Telefone é obrigatório")
+ .refine((phone) => {
+ // Remove todos os caracteres não numéricos
+ const cleaned = phone.replace(/[^\d]/g, "");
+ // Deve ter 10 ou 11 dígitos (telefone fixo ou celular com DDD)
+ return cleaned.length >= 10 && cleaned.length <= 11;
+ }, "Telefone deve ter 10 ou 11 dígitos (incluindo DDD)")
+ .transform((phone) => phone.replace(/[^\d]/g, ""));
+
+// Schema para CPF/CNPJ
+export const documentSchema = z
+ .string()
+ .min(1, "CPF/CNPJ é obrigatório")
+ .refine((doc) => isValidDocument(doc), {
+ message: "CPF ou CNPJ inválido",
+ })
+ .transform((doc) => doc.replace(/[^\d]/g, "")); // Normaliza removendo formatação
+
+// Schema para nome (alfabético, sem números)
+export const nameSchema = z
+ .string()
+ .min(3, "Nome deve ter no mínimo 3 caracteres")
+ .max(100, "Nome deve ter no máximo 100 caracteres")
+ .refine(
+ (name) => /^[A-Za-zÀ-ÿ\s'-]+$/.test(name.trim()),
+ "Nome deve conter apenas letras, espaços e acentos"
+ )
+ .transform((name) => name.trim());
+
+// Schema para mensagem (sanitiza HTML e previne XSS)
+export const messageSchema = z
+ .string()
+ .max(280, "Mensagem deve ter no máximo 280 caracteres")
+ .transform((message) => {
+ // Sanitiza HTML para prevenir XSS
+ const sanitized = sanitizeHtml(message, {
+ allowedTags: [], // Remove todas as tags HTML
+ allowedAttributes: {},
+ });
+ return sanitized.trim();
+ })
+ .optional();
+
+// Schema para tier de doação
+export const donationTierSchema = z.enum(
+ ["coffee", "snack", "lunch", "custom"],
+ {
+ message: "Tier de doação inválido",
+ }
+);
+
+// Schema para valor customizado
+export const customAmountSchema = z
+ .number()
+ .min(5, "Valor mínimo é R$ 5,00")
+ .max(500, "Valor máximo é R$ 500,00");
+
+// Schema completo para criação de doação
+export const createDonationSchema = z
+ .object({
+ tier: donationTierSchema,
+ anonymous: z.boolean().optional().default(false),
+ customAmount: z.number().optional(),
+ })
+ .and(
+ z.union([
+ // Doação anônima - não precisa de dados pessoais
+ z.object({
+ anonymous: z.literal(true),
+ }),
+ // Doação identificada - precisa de todos os dados
+ z.object({
+ anonymous: z.literal(false),
+ donorName: nameSchema,
+ donorEmail: emailSchema,
+ donorPhone: phoneSchema,
+ donorTaxId: documentSchema,
+ donorMessage: messageSchema,
+ }),
+ ])
+ )
+ .refine(
+ (data) => {
+ // Se tier for custom, customAmount é obrigatório
+ if (data.tier === "custom") {
+ return data.customAmount !== undefined;
+ }
+ return true;
+ },
+ {
+ message: "customAmount é obrigatório quando tier é 'custom'",
+ path: ["customAmount"],
+ }
+ )
+ .refine(
+ (data) => {
+ // Se tier for custom, customAmount deve estar no range válido
+ if (data.tier === "custom" && data.customAmount) {
+ return data.customAmount >= 5 && data.customAmount <= 500;
+ }
+ return true;
+ },
+ {
+ message: "Valor deve estar entre R$ 5,00 e R$ 500,00",
+ path: ["customAmount"],
+ }
+ );
+
+// Schema para verificação de status
+export const verifyStatusSchema = z.object({
+ pixId: z.string().min(1, "pixId é obrigatório"),
+});
+
+// Schema para verificação de billing
+export const verifyBillingSchema = z.object({
+ billingId: z.string().min(1, "ID da cobrança é obrigatório"),
+});
+
+// Type exports para uso no código
+export type CreateDonationInput = z.infer;
+export type VerifyStatusInput = z.infer;
+export type VerifyBillingInput = z.infer;
diff --git a/lib/validators/document-validator.ts b/lib/validators/document-validator.ts
new file mode 100644
index 0000000..2486e52
--- /dev/null
+++ b/lib/validators/document-validator.ts
@@ -0,0 +1,85 @@
+/**
+ * Validador de documentos brasileiros (CPF e CNPJ)
+ * Implementa algoritmo completo de validação de dígitos verificadores
+ */
+
+export function isValidCPF(cpf: string): boolean {
+ // Remove caracteres não numéricos
+ const cleaned = cpf.replace(/[^\d]/g, "");
+
+ // Verifica se tem 11 dígitos
+ if (cleaned.length !== 11) return false;
+
+ // Verifica se todos os dígitos são iguais (ex: 111.111.111-11)
+ if (/^(\d)\1+$/.test(cleaned)) return false;
+
+ // Valida primeiro dígito verificador
+ let sum = 0;
+ for (let i = 0; i < 9; i++) {
+ sum += parseInt(cleaned.charAt(i)) * (10 - i);
+ }
+ let remainder = (sum * 10) % 11;
+ if (remainder === 10 || remainder === 11) remainder = 0;
+ if (remainder !== parseInt(cleaned.charAt(9))) return false;
+
+ // Valida segundo dígito verificador
+ sum = 0;
+ for (let i = 0; i < 10; i++) {
+ sum += parseInt(cleaned.charAt(i)) * (11 - i);
+ }
+ remainder = (sum * 10) % 11;
+ if (remainder === 10 || remainder === 11) remainder = 0;
+ if (remainder !== parseInt(cleaned.charAt(10))) return false;
+
+ return true;
+}
+
+export function isValidCNPJ(cnpj: string): boolean {
+ // Remove caracteres não numéricos
+ const cleaned = cnpj.replace(/[^\d]/g, "");
+
+ // Verifica se tem 14 dígitos
+ if (cleaned.length !== 14) return false;
+
+ // Verifica se todos os dígitos são iguais
+ if (/^(\d)\1+$/.test(cleaned)) return false;
+
+ // Valida primeiro dígito verificador
+ const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
+ let sum = 0;
+ for (let i = 0; i < 12; i++) {
+ sum += parseInt(cleaned.charAt(i)) * weights1[i];
+ }
+ let remainder = sum % 11;
+ const firstDigit = remainder < 2 ? 0 : 11 - remainder;
+ if (firstDigit !== parseInt(cleaned.charAt(12))) return false;
+
+ // Valida segundo dígito verificador
+ const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
+ sum = 0;
+ for (let i = 0; i < 13; i++) {
+ sum += parseInt(cleaned.charAt(i)) * weights2[i];
+ }
+ remainder = sum % 11;
+ const secondDigit = remainder < 2 ? 0 : 11 - remainder;
+ if (secondDigit !== parseInt(cleaned.charAt(13))) return false;
+
+ return true;
+}
+
+export function isValidDocument(document: string): boolean {
+ const cleaned = document.replace(/[^\d]/g, "");
+
+ if (cleaned.length === 11) {
+ return isValidCPF(document);
+ } else if (cleaned.length === 14) {
+ return isValidCNPJ(document);
+ }
+
+ return false;
+}
+
+export function normalizeDocument(document: string): string {
+ // Remove todos os caracteres não numéricos
+ return document.replace(/[^\d]/g, "");
+}
diff --git a/messages/en.json b/messages/en.json
index e496758..f25db99 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3,7 +3,8 @@
"nav": {
"market": "Market",
"about": "About",
- "prices": "Prices"
+ "prices": "Prices",
+ "support": "Support"
},
"theme": {
"light": "Light Theme",
@@ -187,5 +188,141 @@
"title": "Prices Page Under Construction",
"description": "We're finalizing our pricing structure and plans. Check back soon!"
}
+ },
+ "donation": {
+ "title": "Support Project Development",
+ "subtitle": "Your voluntary donation helps maintain and evolve the platform",
+ "story": {
+ "title": "Our Project, Our Community",
+ "text": "Tibia Trade Pagodera is developed with dedication to create the best trading experience for our community. Each voluntary donation helps us continue evolving and maintaining service quality.\n\nWe are not a company, we are developers passionate about Tibia who believe in total transparency. Every penny is invested directly in the project.\n\nYour trust and support make all the difference."
+ },
+ "transparency": {
+ "title": "Total Cost Transparency",
+ "hosting": "VPS Infrastructure",
+ "development": "Development",
+ "support": "Maintenance & Support",
+ "emergency": "Emergency Reserve",
+ "hostingDesc": "R$ 69/month - Dedicated server to ensure performance",
+ "developmentDesc": "R$ 50/hour - Real cost of development and improvements",
+ "supportDesc": "Monitoring, bug fixes and customer service",
+ "emergencyDesc": "Buffer for unforeseen situations and scalability"
+ },
+ "costs": {
+ "title": "Estimated Monthly Costs",
+ "vps": "VPS (Server)",
+ "dev": "Development (average 40h/month)",
+ "total": "Total Monthly Estimate",
+ "vpsValue": "R$ 69/month",
+ "devValue": "R$ 2,000/month",
+ "totalValue": "R$ 2,069/month"
+ },
+ "social": {
+ "months": "month online",
+ "uptime": "uptime"
+ },
+ "tiers": {
+ "coffee": {
+ "title": "A Coffee",
+ "description": "Equals ~10 minutes of development",
+ "badge": "Popular"
+ },
+ "snack": {
+ "title": "A Snack",
+ "description": "Equals ~20 minutes of development",
+ "badge": "Popular"
+ },
+ "lunch": {
+ "title": "Team Lunch",
+ "description": "Equals ~30 minutes of development",
+ "badge": "Recommended"
+ },
+ "custom": {
+ "title": "Custom Amount",
+ "description": "You choose how much you want to contribute"
+ }
+ },
+ "cta": {
+ "support": "Make Donation",
+ "share": "Share Project",
+ "final": "Every contribution matters",
+ "subtitle": "Each donation helps us dedicate more time to the project and keep the infrastructure running"
+ },
+ "form": {
+ "chooseAmount": "Choose the amount",
+ "donate": "Donate R$",
+ "processing": "Processing...",
+ "supportWithPix": "Support with PIX",
+ "value": "Amount (R$)",
+ "min": "Min",
+ "max": "Max",
+ "minAmount": "Minimum is R$",
+ "maxAmount": "Maximum is R$",
+ "howToContribute": "How would you like to contribute?",
+ "anonymousTitle": "Anonymous Donation",
+ "anonymousDesc": "Quick and no registration. Generate PIX immediately.",
+ "anonymousButton": "Continue Anonymous",
+ "identifiedTitle": "I Want to Identify Myself",
+ "identifiedDesc": "Help us thank you and offer benefits",
+ "recommended": "Recommended",
+ "thankByEmail": "Personalized thank you by email",
+ "earlyAccess": "Early access to new features",
+ "contributionHistory": "History of your contributions",
+ "identifyMe": "Identify Me",
+ "donorInfo": "Donation Information",
+ "name": "Name *",
+ "nameHint": "min. 3 characters",
+ "namePlaceholder": "Your name or nickname",
+ "email": "Email *",
+ "emailPlaceholder": "your@email.com",
+ "phone": "Phone *",
+ "phoneHint": "with area code",
+ "phonePlaceholder": "(11) 98888-7777",
+ "taxId": "CPF/CNPJ *",
+ "taxIdHint": "for receipt issuance",
+ "taxIdPlaceholder": "000.000.000-00 or 00.000.000/0000-00",
+ "message": "Message (optional)",
+ "messageHint": "max. 280 characters",
+ "messagePlaceholder": "Leave a message for the team...",
+ "generatePix": "Generate PIX",
+ "qrTitle": "Scan the QR Code to Pay",
+ "copyCode": "Or copy the PIX code:",
+ "copy": "Copy",
+ "copied": "Copied!",
+ "expiresIn": "Expires in:",
+ "expired": "Expired",
+ "remainingTime": "Remaining time:",
+ "paid": "I Already Paid",
+ "close": "Close",
+ "back": "Back",
+ "paymentConfirmed": "Payment Confirmed!",
+ "paymentConfirmedDesc": "Thank you for your contribution! Each donation helps us keep the project running.",
+ "contributionMatters": "Your donation makes all the difference for our community. Thank you very much for your support!",
+ "viewThankYouPage": "View Thank You Page",
+ "backToHome": "Back to Home"
+ },
+ "faq": {
+ "title": "Frequently Asked Questions",
+ "q1": "Is the project free?",
+ "a1": "The project charges transaction fees to sustain itself. Donations are voluntary for those who want to contribute beyond normal usage fees.",
+ "q2": "Where does my donation go?",
+ "a2": "100% invested in the project: server cost (R$ 69/month) and development hours (R$ 50/hour). We maintain total transparency of expenses.",
+ "q3": "How does PIX payment work?",
+ "a3": "You choose the amount, we generate a QR Code or PIX code, you pay through your banking app. Instant and secure payment.",
+ "q4": "Do I get anything in return?",
+ "a4": "We don't offer direct benefits. Your donation is voluntary and helps the project evolve for everyone.",
+ "q5": "Are you a company?",
+ "a5": "No. We are independent developers passionate about Tibia. Transparency and community are our values."
+ },
+ "supporters": {
+ "title": "Our Amazing Supporters",
+ "viewAll": "View all supporters"
+ },
+ "thankYou": {
+ "title": "Thank you for your support!",
+ "subtitle": "Your contribution makes all the difference to our community",
+ "message": "We received your donation and are very grateful for your support. With people like you, we can keep Tibia Trade Pagodera free and constantly improve our platform.",
+ "share": "Share with friends",
+ "back": "Back to home"
+ }
}
}
diff --git a/messages/pt.json b/messages/pt.json
index 5e51667..7452aff 100644
--- a/messages/pt.json
+++ b/messages/pt.json
@@ -3,7 +3,8 @@
"nav": {
"market": "Mercado",
"about": "Sobre",
- "prices": "Preços"
+ "prices": "Preços",
+ "support": "Apoiar"
},
"theme": {
"light": "Tema Claro",
@@ -187,5 +188,141 @@
"title": "Página de Preços em Construção",
"description": "Estamos finalizando nossa estrutura de preços e planos. Volte em breve para conferir!"
}
+ },
+ "donation": {
+ "title": "Apoie o Desenvolvimento do Projeto",
+ "subtitle": "Sua doação voluntária ajuda a manter e evoluir a plataforma",
+ "story": {
+ "title": "Nosso Projeto, Nossa Comunidade",
+ "text": "O Tibia Trade Pagodera é desenvolvido com dedicação para criar a melhor experiência de trading para nossa comunidade. Cada doação voluntária nos ajuda a continuar evoluindo e mantendo a qualidade do serviço.\n\nNão somos uma empresa, somos desenvolvedores apaixonados por Tibia que acreditam em transparência total. Todo centavo é investido diretamente no projeto.\n\nSua confiança e apoio fazem toda a diferença."
+ },
+ "transparency": {
+ "title": "Transparência Total de Custos",
+ "hosting": "Infraestrutura VPS",
+ "development": "Desenvolvimento",
+ "support": "Manutenção & Suporte",
+ "emergency": "Reserva para Emergências",
+ "hostingDesc": "R$ 69/mês - Servidor dedicado para garantir performance",
+ "developmentDesc": "R$ 50/hora - Custo real de desenvolvimento e melhorias",
+ "supportDesc": "Monitoramento, correções de bugs e atendimento",
+ "emergencyDesc": "Buffer para situações imprevistas e escalabilidade"
+ },
+ "costs": {
+ "title": "Custos Mensais Estimados",
+ "vps": "VPS (Servidor)",
+ "dev": "Desenvolvimento (média 40h/mês)",
+ "total": "Total Mensal Estimado",
+ "vpsValue": "R$ 69/mês",
+ "devValue": "R$ 2.000/mês",
+ "totalValue": "R$ 2.069/mês"
+ },
+ "social": {
+ "months": "mês online",
+ "uptime": "uptime"
+ },
+ "tiers": {
+ "coffee": {
+ "title": "Um Café",
+ "description": "Equivale a ~10 minutos de desenvolvimento",
+ "badge": "Popular"
+ },
+ "snack": {
+ "title": "Um Lanche",
+ "description": "Equivale a ~20 minutos de desenvolvimento",
+ "badge": "Popular"
+ },
+ "lunch": {
+ "title": "Almoço da Equipe",
+ "description": "Equivale a ~30 minutos de desenvolvimento",
+ "badge": "Recomendado"
+ },
+ "custom": {
+ "title": "Valor Livre",
+ "description": "Você escolhe quanto quer contribuir"
+ }
+ },
+ "cta": {
+ "support": "Fazer Doação",
+ "share": "Compartilhar Projeto",
+ "final": "Toda contribuição é importante",
+ "subtitle": "Cada doação nos ajuda a dedicar mais tempo ao projeto e manter a infraestrutura funcionando"
+ },
+ "form": {
+ "chooseAmount": "Escolha o valor",
+ "donate": "Doar R$",
+ "processing": "Processando...",
+ "supportWithPix": "Apoiar com PIX",
+ "value": "Valor (R$)",
+ "min": "Mín",
+ "max": "Máx",
+ "minAmount": "Mínimo é R$",
+ "maxAmount": "Máximo é R$",
+ "howToContribute": "Como deseja contribuir?",
+ "anonymousTitle": "Doação Anônima",
+ "anonymousDesc": "Rápido e sem cadastro. Gere o PIX imediatamente.",
+ "anonymousButton": "Continuar Anônimo",
+ "identifiedTitle": "Quero me Identificar",
+ "identifiedDesc": "Ajude-nos a agradecer e oferecer benefícios",
+ "recommended": "Recomendado",
+ "thankByEmail": "Agradecimento personalizado por email",
+ "earlyAccess": "Acesso antecipado a novos recursos",
+ "contributionHistory": "Histórico de suas contribuições",
+ "identifyMe": "Identificar-me",
+ "donorInfo": "Informações para Doação",
+ "name": "Nome *",
+ "nameHint": "min. 3 caracteres",
+ "namePlaceholder": "Seu nome ou apelido",
+ "email": "Email *",
+ "emailPlaceholder": "seu@email.com",
+ "phone": "Telefone *",
+ "phoneHint": "com DDD",
+ "phonePlaceholder": "(11) 98888-7777",
+ "taxId": "CPF/CNPJ *",
+ "taxIdHint": "para emissão de recibo",
+ "taxIdPlaceholder": "000.000.000-00 ou 00.000.000/0000-00",
+ "message": "Mensagem (opcional)",
+ "messageHint": "máx. 280 caracteres",
+ "messagePlaceholder": "Deixe uma mensagem para a equipe...",
+ "generatePix": "Gerar PIX",
+ "qrTitle": "Escaneie o QR Code para Pagar",
+ "copyCode": "Ou copie o código PIX:",
+ "copy": "Copiar",
+ "copied": "Copiado!",
+ "expiresIn": "Expira em:",
+ "expired": "Expirado",
+ "remainingTime": "Tempo restante:",
+ "paid": "Já Paguei",
+ "close": "Fechar",
+ "back": "Voltar",
+ "paymentConfirmed": "Pagamento Confirmado!",
+ "paymentConfirmedDesc": "Obrigado pela sua contribuição! Cada doação nos ajuda a manter o projeto funcionando.",
+ "contributionMatters": "Sua doação faz toda a diferença para nossa comunidade. Muito obrigado pelo apoio!",
+ "viewThankYouPage": "Ver Página de Agradecimento",
+ "backToHome": "Voltar ao Início"
+ },
+ "faq": {
+ "title": "Perguntas Frequentes",
+ "q1": "O projeto é gratuito?",
+ "a1": "O projeto cobra taxas de transação para se manter. As doações são voluntárias para quem deseja contribuir além das taxas normais de uso.",
+ "q2": "Para onde vai minha doação?",
+ "a2": "100% investido no projeto: custo de servidor (R$ 69/mês) e horas de desenvolvimento (R$ 50/hora). Mantemos transparência total dos gastos.",
+ "q3": "Como funciona o pagamento PIX?",
+ "a3": "Você escolhe o valor, geramos um QR Code ou código PIX, você paga pelo seu app bancário. Pagamento instantâneo e seguro.",
+ "q4": "Recebo algo em troca?",
+ "a4": "Não oferecemos benefícios diretos. Sua doação é voluntária e ajuda o projeto a evoluir para todos.",
+ "q5": "Vocês são uma empresa?",
+ "a5": "Não. Somos desenvolvedores independentes apaixonados por Tibia. Transparência e comunidade são nossos valores."
+ },
+ "supporters": {
+ "title": "Nossos Incríveis Apoiadores",
+ "viewAll": "Ver todos os apoiadores"
+ },
+ "thankYou": {
+ "title": "Obrigado pelo seu apoio!",
+ "subtitle": "Sua contribuição faz toda a diferença para nossa comunidade",
+ "message": "Recebemos sua doação e ficamos muito gratos pelo seu apoio. Com pessoas como você, conseguimos manter o Tibia Trade Pagodera gratuito e melhorar constantemente nossa plataforma.",
+ "share": "Compartilhe com amigos",
+ "back": "Voltar ao início"
+ }
}
}
diff --git a/next.config.ts b/next.config.ts
index 7a48280..18ee50d 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -5,6 +5,28 @@ const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
const nextConfig: NextConfig = {
/* config options here */
+ async headers() {
+ return [
+ {
+ // Aplicar CORS apenas para rotas de API
+ source: "/api/webhooks/:path*",
+ headers: [
+ {
+ key: "Access-Control-Allow-Origin",
+ value: "*", // AbacatePay envia de IPs variados
+ },
+ {
+ key: "Access-Control-Allow-Methods",
+ value: "POST, GET, OPTIONS",
+ },
+ {
+ key: "Access-Control-Allow-Headers",
+ value: "Content-Type, Authorization",
+ },
+ ],
+ },
+ ];
+ },
};
export default withNextIntl(nextConfig);
diff --git a/package.json b/package.json
index 865f444..e60c71b 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,8 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
+ "@types/canvas-confetti": "^1.9.0",
+ "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.5",
@@ -29,8 +31,12 @@
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-qr-code": "^2.0.18",
"recharts": "^3.3.0",
- "tailwind-merge": "^3.3.1"
+ "sanitize-html": "^2.17.0",
+ "tailwind-merge": "^3.3.1",
+ "validator": "^13.15.20",
+ "zod": "^4.1.12"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
@@ -38,6 +44,8 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/sanitize-html": "^2.16.0",
+ "@types/validator": "^13.15.3",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.7",
"typescript": "^5"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d18b300..2b5c0e1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,12 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@types/canvas-confetti':
+ specifier: ^1.9.0
+ version: 1.9.0
+ canvas-confetti:
+ specifier: ^1.9.4
+ version: 1.9.4
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -65,12 +71,24 @@ importers:
react-dom:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
+ react-qr-code:
+ specifier: ^2.0.18
+ version: 2.0.18(react@19.1.0)
recharts:
specifier: ^3.3.0
version: 3.3.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react-is@19.2.0)(react@19.1.0)(redux@5.0.1)
+ sanitize-html:
+ specifier: ^2.17.0
+ version: 2.17.0
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
+ validator:
+ specifier: ^13.15.20
+ version: 13.15.20
+ zod:
+ specifier: ^4.1.12
+ version: 4.1.12
devDependencies:
'@biomejs/biome':
specifier: 2.2.0
@@ -87,6 +105,12 @@ importers:
'@types/react-dom':
specifier: ^19
version: 19.2.2(@types/react@19.2.2)
+ '@types/sanitize-html':
+ specifier: ^2.16.0
+ version: 2.16.0
+ '@types/validator':
+ specifier: ^13.15.3
+ version: 13.15.3
tailwindcss:
specifier: ^4
version: 4.1.15
@@ -889,6 +913,9 @@ packages:
'@tailwindcss/postcss@4.1.15':
resolution: {integrity: sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==}
+ '@types/canvas-confetti@1.9.0':
+ resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
+
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -927,9 +954,15 @@ packages:
'@types/react@19.2.2':
resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==}
+ '@types/sanitize-html@2.16.0':
+ resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
+
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
+ '@types/validator@13.15.3':
+ resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==}
+
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@@ -937,6 +970,9 @@ packages:
caniuse-lite@1.0.30001751:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
+ canvas-confetti@1.9.4:
+ resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
+
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -1003,6 +1039,10 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+ deepmerge@4.3.1:
+ resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+ engines: {node: '>=0.10.0'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1010,13 +1050,34 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+ domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+ domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+
+ domutils@3.2.2:
+ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
es-toolkit@1.40.0:
resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==}
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@@ -1041,6 +1102,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ htmlparser2@8.0.2:
+ resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
+
immer@10.1.3:
resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==}
@@ -1051,10 +1115,17 @@ packages:
intl-messageformat@10.7.18:
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
+ is-plain-object@5.0.0:
+ resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
+ engines: {node: '>=0.10.0'}
+
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@@ -1125,6 +1196,10 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
lucide-react@0.540.0:
resolution: {integrity: sha512-armkCAqQvO62EIX4Hq7hqX/q11WSZu0Jd23cnnqx0/49yIxGXyL/zyZfBxNN9YDx0ensPTb4L+DjTh3yQXUxtQ==}
peerDependencies:
@@ -1199,6 +1274,13 @@ packages:
sass:
optional: true
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ parse-srcset@1.0.2:
+ resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
+
phenomenon@1.6.0:
resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==}
@@ -1213,14 +1295,28 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
+ qr.js@0.0.0:
+ resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
+
react-dom@19.1.0:
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
peerDependencies:
react: ^19.1.0
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
react-is@19.2.0:
resolution: {integrity: sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==}
+ react-qr-code@2.0.18:
+ resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==}
+ peerDependencies:
+ react: '*'
+
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -1286,6 +1382,9 @@ packages:
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
+ sanitize-html@2.17.0:
+ resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
+
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
@@ -1372,9 +1471,16 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ validator@13.15.20:
+ resolution: {integrity: sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==}
+ engines: {node: '>= 0.10'}
+
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
+ zod@4.1.12:
+ resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
+
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -2072,6 +2178,8 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.15
+ '@types/canvas-confetti@1.9.0': {}
+
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
@@ -2108,14 +2216,22 @@ snapshots:
dependencies:
csstype: 3.1.3
+ '@types/sanitize-html@2.16.0':
+ dependencies:
+ htmlparser2: 8.0.2
+
'@types/use-sync-external-store@0.0.6': {}
+ '@types/validator@13.15.3': {}
+
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
caniuse-lite@1.0.30001751: {}
+ canvas-confetti@1.9.4: {}
+
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -2172,17 +2288,41 @@ snapshots:
decimal.js@10.6.0: {}
+ deepmerge@4.3.1: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
+ dom-serializer@2.0.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+
+ domelementtype@2.3.0: {}
+
+ domhandler@5.0.3:
+ dependencies:
+ domelementtype: 2.3.0
+
+ domutils@3.2.2:
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
+ entities@4.5.0: {}
+
es-toolkit@1.40.0: {}
+ escape-string-regexp@4.0.0: {}
+
eventemitter3@5.0.1: {}
framer-motion@12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
@@ -2198,6 +2338,13 @@ snapshots:
graceful-fs@4.2.11: {}
+ htmlparser2@8.0.2:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ entities: 4.5.0
+
immer@10.1.3: {}
internmap@2.0.3: {}
@@ -2209,8 +2356,12 @@ snapshots:
'@formatjs/icu-messageformat-parser': 2.11.4
tslib: 2.8.1
+ is-plain-object@5.0.0: {}
+
jiti@2.6.1: {}
+ js-tokens@4.0.0: {}
+
lightningcss-android-arm64@1.30.2:
optional: true
@@ -2260,6 +2411,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
lucide-react@0.540.0(react@19.1.0):
dependencies:
react: 19.1.0
@@ -2324,6 +2479,10 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
+ object-assign@4.1.1: {}
+
+ parse-srcset@1.0.2: {}
+
phenomenon@1.6.0: {}
picocolors@1.1.1: {}
@@ -2340,13 +2499,29 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+ qr.js@0.0.0: {}
+
react-dom@19.1.0(react@19.1.0):
dependencies:
react: 19.1.0
scheduler: 0.26.0
+ react-is@16.13.1: {}
+
react-is@19.2.0: {}
+ react-qr-code@2.0.18(react@19.1.0):
+ dependencies:
+ prop-types: 15.8.1
+ qr.js: 0.0.0
+ react: 19.1.0
+
react-redux@9.2.0(@types/react@19.2.2)(react@19.1.0)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -2413,6 +2588,15 @@ snapshots:
reselect@5.1.1: {}
+ sanitize-html@2.17.0:
+ dependencies:
+ deepmerge: 4.3.1
+ escape-string-regexp: 4.0.0
+ htmlparser2: 8.0.2
+ is-plain-object: 5.0.0
+ parse-srcset: 1.0.2
+ postcss: 8.5.6
+
scheduler@0.26.0: {}
semver@7.7.3:
@@ -2497,6 +2681,8 @@ snapshots:
dependencies:
react: 19.1.0
+ validator@13.15.20: {}
+
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
@@ -2513,3 +2699,5 @@ snapshots:
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
+
+ zod@4.1.12: {}
diff --git a/types/donation.ts b/types/donation.ts
new file mode 100644
index 0000000..fcd4aa4
--- /dev/null
+++ b/types/donation.ts
@@ -0,0 +1,97 @@
+export interface DonationTier {
+ id: string;
+ amount?: number;
+ title: string;
+ description: string;
+ icon: "coffee" | "pizza" | "heart";
+ badge?: string;
+ highlighted?: boolean;
+}
+
+export interface DonationData {
+ amount: number;
+ donorName?: string;
+ donorEmail?: 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,
+ },
+ custom: {
+ id: "custom",
+ name: "Valor Personalizado",
+ amount: 0,
+ price: 0,
+ minAmount: 1, // R$ 1,00
+ maxAmount: 10000, // R$ 10.000,00
+ },
+} 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;
+ pixId?: string;
+ brCode?: string;
+ brCodeBase64?: string;
+ expiresAt?: string;
+ error?: string;
+}
+
+export interface BillingStatus {
+ id: string;
+ amount: number;
+ status: "PENDING" | "PAID" | "EXPIRED" | "CANCELLED";
+ pixCode: string;
+ pixQrCode: string;
+ expiresAt: string;
+ createdAt: string;
+}
+
+export interface DonationStats {
+ totalSupporters: number;
+ totalRaised: number;
+ monthsOnline: number;
+ uptime: number;
+}
+
+export interface TransparencyData {
+ hosting: number;
+ development: number;
+ support: number;
+ emergency: number;
+}
+
+export interface Supporter {
+ id: string;
+ name: string;
+ avatar?: string;
+ amount: number;
+ message?: string;
+ createdAt: string;
+ anonymous?: boolean;
+}