Merge pull request #1 from evandrodevbr:develop

Develop
This commit is contained in:
Evandro Fonseca Junior authored and GitHub committed 2025-10-26 23:04:03 -03:00
commit d7af4a51fa
27 files changed
+3418 -5

No files matched your search

+70
View File
@@ -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
+162
View File
@@ -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
+120
View File
@@ -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 (
<div className="min-h-screen">
<Header />
<div className="container mx-auto px-4 py-20">
<BlurFade delay={0.1}>
<div className="max-w-2xl mx-auto text-center">
{/* Ícone de sucesso animado */}
<div className="flex justify-center mb-8">
<div className="relative">
<div className="absolute inset-0 bg-green-500/20 rounded-full animate-ping" />
<div className="relative p-6 rounded-full bg-green-500/10 border-4 border-green-500">
<Heart
className="w-20 h-20 text-green-500"
fill="currentColor"
/>
</div>
</div>
</div>
{/* Título e subtítulo */}
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Muito Obrigado! 🎉
</h1>
<p className="text-xl text-muted-foreground mb-8">
Sua doação foi confirmada com sucesso!
</p>
{/* Mensagem de agradecimento */}
<div className="p-8 rounded-2xl border border-border/50 bg-card backdrop-blur-lg mb-8">
<p className="text-lg leading-relaxed text-muted-foreground">
Sua generosidade nos ajuda a continuar desenvolvendo e mantendo
o Tibia Trade Pagodera. Cada contribuição faz toda a diferença
para nossa comunidade!
</p>
</div>
{/* Botões de ação */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href={`/${locale}`}>
<Button size="lg" className="w-full sm:w-auto">
<Home className="mr-2 h-5 w-5" />
Voltar ao Início
</Button>
</Link>
<Link href={`/${locale}/apoiar`}>
<Button
size="lg"
variant="outline"
className="w-full sm:w-auto"
>
<Heart className="mr-2 h-5 w-5" />
Fazer Outra Doação
</Button>
</Link>
<Button
size="lg"
variant="secondary"
className="w-full sm:w-auto"
onClick={() => {
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!");
}
}}
>
<Share2 className="mr-2 h-5 w-5" />
Compartilhar
</Button>
</div>
{/* Informação adicional */}
<div className="mt-12 p-6 rounded-xl bg-muted/30 border border-border/30">
<p className="text-sm text-muted-foreground">
💡 <strong>Dica:</strong> Compartilhe nossa plataforma com seus
amigos! Quanto mais pessoas usarem o Tibia Trade Pagodera, mais
forte nossa comunidade fica.
</p>
</div>
</div>
</BlurFade>
</div>
</div>
);
}
+250
View File
@@ -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 (
<div className="min-h-screen">
<Header />
<div className="container mx-auto px-4 py-20">
{/* Hero Section */}
<BlurFade delay={0.1}>
<div className="text-center mb-16">
<div className="flex justify-center mb-6">
<div className="p-4 rounded-full bg-primary/10 border border-primary/20">
<Heart className="w-12 h-12 text-primary" />
</div>
</div>
<h1 className="text-4xl md:text-5xl font-bold mb-4">
{t("title")}
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
{t("subtitle")}
</p>
</div>
</BlurFade>
{/* História & Missão */}
<BlurFade delay={0.2}>
<div className="mb-20">
<h2 className="text-3xl font-bold text-center mb-8">
{t("story.title")}
</h2>
<div className="max-w-4xl mx-auto">
<div className="p-8 rounded-2xl border border-border/50 bg-card backdrop-blur-lg">
<p className="text-lg leading-relaxed text-muted-foreground whitespace-pre-line">
{t("story.text")}
</p>
</div>
</div>
</div>
</BlurFade>
{/* Custos Reais */}
<BlurFade delay={0.3}>
<div className="mb-20">
<h2 className="text-3xl font-bold text-center mb-8">
{t("costs.title")}
</h2>
<div className="max-w-2xl mx-auto space-y-4">
<div className="flex justify-between items-center p-4 rounded-lg bg-card border">
<span className="text-lg">{t("costs.vps")}</span>
<Badge variant="secondary" className="text-lg">
{t("costs.vpsValue")}
</Badge>
</div>
<div className="flex justify-between items-center p-4 rounded-lg bg-card border">
<span className="text-lg">{t("costs.dev")}</span>
<Badge variant="secondary" className="text-lg">
{t("costs.devValue")}
</Badge>
</div>
<div className="flex justify-between items-center p-6 rounded-lg bg-primary/10 border-2 border-primary">
<span className="text-xl font-bold">{t("costs.total")}</span>
<Badge className="text-xl bg-primary">
{t("costs.totalValue")}
</Badge>
</div>
</div>
</div>
</BlurFade>
{/* Estatísticas Simplificadas */}
<BlurFade delay={0.4}>
<div className="mb-20">
<div className="grid grid-cols-2 gap-8 text-center max-w-md mx-auto">
<div>
<p className="text-3xl font-bold text-primary mb-2">
<NumberTicker value={stats.monthsOnline} />
</p>
<p className="text-sm text-muted-foreground">
{t("social.months")}
</p>
</div>
<div>
<p className="text-3xl font-bold text-primary mb-2">
<NumberTicker value={stats.uptime} />%
</p>
<p className="text-sm text-muted-foreground">
{t("social.uptime")}
</p>
</div>
</div>
</div>
</BlurFade>
{/* Opções de Doação */}
<BlurFade delay={0.5}>
<div className="mb-20">
<h2 className="text-3xl font-bold text-center mb-8">
{t("cta.support")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
<DonationCard
id="coffee"
amount={5}
title={t("tiers.coffee.title")}
description={t("tiers.coffee.description")}
icon="coffee"
badge={t("tiers.coffee.badge")}
/>
<DonationCard
id="snack"
amount={10}
title={t("tiers.snack.title")}
description={t("tiers.snack.description")}
icon="pizza"
badge={t("tiers.snack.badge")}
highlighted
/>
<DonationCard
id="lunch"
amount={25}
title={t("tiers.lunch.title")}
description={t("tiers.lunch.description")}
icon="pizza"
badge={t("tiers.lunch.badge")}
/>
<DonationCard
id="custom"
title={t("tiers.custom.title")}
description={t("tiers.custom.description")}
icon="heart"
/>
</div>
</div>
</BlurFade>
{/* FAQ */}
<BlurFade delay={0.6}>
<div className="mb-20">
<h2 className="text-3xl font-bold text-center mb-8">
{t("faq.title")}
</h2>
<Accordion type="single" collapsible className="max-w-3xl mx-auto">
{faqItems.map((item, idx) => (
<AccordionItem key={idx} value={`item-${idx}`}>
<AccordionTrigger className="text-left">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</BlurFade>
{/* CTA Final */}
<BlurFade delay={0.7}>
<div className="text-center">
<div className="p-12 rounded-2xl border border-border/50 bg-card backdrop-blur-sm">
<h3 className="text-3xl font-bold mb-4">{t("cta.final")}</h3>
<p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto">
{t("cta.subtitle")}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" className="bg-primary hover:bg-primary/90">
{t("cta.support")}
</Button>
<ShareButton />
</div>
</div>
</div>
</BlurFade>
</div>
</div>
);
}
+8
View File
@@ -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() {
<MarketStats />
</main>
<EscrowExplanation />
{/* Card de Doação no Footer */}
<section className="py-16 bg-muted/20">
<div className="container mx-auto px-4">
<DonationFooterCard />
</div>
</section>
</div>
);
}
+123
View File
@@ -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 }
);
}
}
+44
View File
@@ -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 }
);
}
}
+48
View File
@@ -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 }
);
}
}
+156
View File
@@ -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 }
);
}
}
+5 -2
View File
@@ -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 */}
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
{links.map(({ href, label }) => (
{links.map(({ href, label, highlight }) => (
<NavigationMenuItem key={href}>
<NavigationMenuLink asChild>
<Link
href={href}
className={cn(
navigationMenuTriggerStyle(),
"bg-transparent"
"bg-transparent",
highlight &&
"bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
{label}
+865
View File
@@ -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<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<>
<div
className={`
relative p-8 rounded-2xl border bg-card backdrop-blur-lg
hover:scale-105 transition-all duration-300
${
highlighted
? "border-primary/50 shadow-[0_0_30px_rgba(34,197,94,0.3)]"
: "border-border/50"
}
`}
>
{badge && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary">
{badge}
</Badge>
)}
<div className="flex flex-col items-center text-center space-y-4">
<div className="p-4 rounded-full bg-muted/50 border border-border/30">
<Icon className="w-12 h-12 text-primary" />
</div>
<h3 className="text-2xl font-bold">{title}</h3>
<p className="text-muted-foreground">{description}</p>
{id === "custom" ? (
<>
<div className="w-full space-y-4">
<div className="space-y-2">
<label
htmlFor="customAmount"
className="text-sm text-muted-foreground"
>
{t("form.value")}
</label>
<Input
id="customAmount"
type="text"
inputMode="decimal"
placeholder={t("form.chooseAmount")}
value={customAmount}
onChange={handleCustomAmountChange}
className="text-lg text-center"
/>
<p className="text-xs text-muted-foreground">
{t("form.min")}: R$ {customProduct.minAmount.toFixed(2)} |{" "}
{t("form.max")}: R$ {customProduct.maxAmount.toFixed(2)}
</p>
</div>
{customAmount && !isValidCustomAmount() && (
<p className="text-xs text-red-500">
{parseFloat(customAmount) < customProduct.minAmount
? `${t(
"form.minAmount"
)} ${customProduct.minAmount.toFixed(2)}`
: `${t(
"form.maxAmount"
)} ${customProduct.maxAmount.toFixed(2)}`}
</p>
)}
</div>
<Button
size="lg"
className="w-full"
onClick={handleCustomDonationClick}
disabled={loading || !isValidCustomAmount()}
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t("form.processing")}
</>
) : customAmount ? (
`${t("form.donate")} ${parseFloat(
customAmount || "0"
).toFixed(2)}`
) : (
t("form.chooseAmount")
)}
</Button>
</>
) : (
<>
{amount && (
<div className="text-4xl font-bold text-primary">
R$ {amount.toFixed(2)}
</div>
)}
<Button
size="lg"
className="w-full"
onClick={handleDonateClick}
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t("form.processing")}
</>
) : (
t("form.supportWithPix")
)}
</Button>
</>
)}
</div>
</div>
{/* Modal de Escolha (Anônimo vs Identificado) */}
<Dialog open={showChoiceModal} onOpenChange={setShowChoiceModal}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="text-center text-2xl">
{t("form.howToContribute")}
</DialogTitle>
</DialogHeader>
<div className="grid md:grid-cols-2 gap-4 mt-6">
{/* Card Anônimo */}
<div
className="p-6 rounded-xl border border-border/50 bg-card hover:border-primary/30 transition-all cursor-pointer"
onClick={handleAnonymousDonation}
>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-full bg-muted">
<Lock className="h-5 w-5" />
</div>
<h3 className="text-xl font-bold">
{t("form.anonymousTitle")}
</h3>
</div>
<p className="text-muted-foreground mb-4">
{t("form.anonymousDesc")}
</p>
<Button className="w-full" variant="outline" disabled={loading}>
{t("form.anonymousButton")}
</Button>
</div>
{/* Card Identificado */}
<div
className="p-6 rounded-xl border-2 border-primary/50 bg-primary/5 hover:border-primary transition-all cursor-pointer relative"
onClick={handleIdentifiedClick}
>
<Badge className="absolute -top-3 right-6 bg-primary">
{t("form.recommended")}
</Badge>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<h3 className="text-xl font-bold">
{t("form.identifiedTitle")}
</h3>
</div>
<p className="text-muted-foreground mb-4">
{t("form.identifiedDesc")}
</p>
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-primary" />
<span>{t("form.thankByEmail")}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-primary" />
<span>{t("form.earlyAccess")}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4 text-primary" />
<span>{t("form.contributionHistory")}</span>
</div>
</div>
<Button className="w-full" disabled={loading}>
{t("form.identifyMe")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Modal de Formulário do Doador Identificado */}
<Dialog open={showDonorForm} onOpenChange={setShowDonorForm}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("form.donorInfo")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/50 rounded-lg text-destructive text-sm">
{error}
</div>
)}
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.name")}{" "}
<span className="text-muted-foreground text-xs">
({t("form.nameHint")})
</span>
</label>
<Input
value={donorName}
onChange={(e) => setDonorName(e.target.value)}
placeholder={t("form.namePlaceholder")}
maxLength={50}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.email")}
</label>
<Input
type="email"
value={donorEmail}
onChange={(e) => setDonorEmail(e.target.value)}
placeholder={t("form.emailPlaceholder")}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.phone")}{" "}
<span className="text-muted-foreground text-xs">
({t("form.phoneHint")})
</span>
</label>
<Input
value={donorPhone}
onChange={(e) => setDonorPhone(formatPhone(e.target.value))}
placeholder={t("form.phonePlaceholder")}
maxLength={15}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.taxId")}{" "}
<span className="text-muted-foreground text-xs">
({t("form.taxIdHint")})
</span>
</label>
<Input
value={donorTaxId}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, "");
if (value.length <= 11) {
setDonorTaxId(formatCPF(value));
} else {
setDonorTaxId(formatCNPJ(value));
}
}}
placeholder={t("form.taxIdPlaceholder")}
maxLength={18}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("form.message")}{" "}
<span className="text-muted-foreground text-xs">
({t("form.messageHint")})
</span>
</label>
<Textarea
value={donorMessage}
onChange={(e) => {
if (e.target.value.length <= 280) {
setDonorMessage(e.target.value);
}
}}
placeholder={t("form.messagePlaceholder")}
rows={3}
maxLength={280}
/>
<p className="text-xs text-muted-foreground text-right mt-1">
{donorMessage.length}/280
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
setShowDonorForm(false);
setShowChoiceModal(true);
setError(null);
}}
variant="outline"
className="flex-1"
disabled={loading}
>
{t("form.close")}
</Button>
<Button
onClick={handleSubmitForm}
className="flex-1"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("form.processing")}
</>
) : (
t("form.generatePix")
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Modal de QR Code PIX */}
<Dialog open={showPixModal} onOpenChange={setShowPixModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-center">
{t("form.qrTitle")}
</DialogTitle>
</DialogHeader>
{pixData && (
<div className="space-y-4">
<div className="flex justify-center p-4 bg-white rounded-lg">
<img
src={pixData.brCodeBase64}
alt="QR Code PIX"
className="w-64 h-64"
/>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground text-center">
{t("form.copyCode")}
</p>
<div className="flex gap-2">
<Input
value={pixData.brCode}
readOnly
className="font-mono text-sm"
/>
<Button
onClick={handleCopyPixCode}
variant="outline"
size="sm"
>
{copied ? (
<>
<Check className="h-4 w-4" />
{t("form.copied")}
</>
) : (
<>
<Copy className="h-4 w-4" />
{t("form.copy")}
</>
)}
</Button>
</div>
</div>
{timeRemaining && (
<p className="text-xs text-center text-muted-foreground">
⏱️ {t("form.remainingTime")} {timeRemaining}
</p>
)}
<div className="flex gap-2">
<Button
onClick={() => setShowPixModal(false)}
variant="outline"
className="flex-1"
>
{t("form.close")}
</Button>
<Button
onClick={() => (window.location.href = "/apoiar/obrigado")}
className="flex-1"
>
{t("form.paid")}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Modal de Agradecimento */}
<Dialog open={showThankYouModal} onOpenChange={setShowThankYouModal}>
<DialogContent className="max-w-md">
<div className="text-center space-y-6 py-4">
{/* Ícone de sucesso animado */}
<div className="flex justify-center">
<div className="relative">
<div className="absolute inset-0 bg-green-500/20 rounded-full animate-ping" />
<div className="relative p-4 rounded-full bg-green-500/10 border-4 border-green-500">
<CheckCircle2 className="w-16 h-16 text-green-500" />
</div>
</div>
</div>
{/* Título e mensagem */}
<div>
<h2 className="text-2xl font-bold mb-2">
{t("form.paymentConfirmed")} 🎉
</h2>
<p className="text-muted-foreground">
{t("form.paymentConfirmedDesc")}
</p>
</div>
{/* Mensagem especial */}
<div className="p-4 rounded-lg bg-primary/5 border border-primary/20">
<p className="text-sm text-muted-foreground">
{t("form.contributionMatters")} ❤️
</p>
</div>
{/* Botões */}
<div className="flex gap-3">
<Button
onClick={() => {
setShowThankYouModal(false);
window.location.href = "/apoiar/obrigado";
}}
className="flex-1"
size="lg"
>
{t("form.viewThankYouPage")}
</Button>
<Button
onClick={() => {
setShowThankYouModal(false);
window.location.href = "/";
}}
variant="outline"
className="flex-1"
size="lg"
>
{t("form.backToHome")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+35
View File
@@ -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 (
<BlurFade delay={0.1}>
<div className="p-6 rounded-xl border border-border/50 bg-card backdrop-blur-lg hover:border-primary/30 hover:shadow-lg transition-all duration-300">
<div className="flex items-center gap-4">
<div className="p-3 rounded-full bg-primary/10 border border-primary/20">
<Heart className="w-8 h-8 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-lg mb-1">{t("title")}</h3>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<Link href="/apoiar">
<Button size="sm" className="bg-primary hover:bg-primary/90">
<Coffee className="mr-2 h-4 w-4" />
Apoiar
</Button>
</Link>
</div>
</div>
</BlurFade>
);
}
+26
View File
@@ -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 (
<Button size="lg" variant="outline" onClick={handleShare}>
<Share2 className="mr-2 h-5 w-5" />
Compartilhar Projeto
</Button>
);
}
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };
+195
View File
@@ -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<BillingResponse> {
const requestBody: any = {
amount: params.amount,
expiresIn: params.expiresIn || 600, // 10 minutos por padrão
description: params.description,
};
if (params.customer) {
requestBody.customer = params.customer;
}
// 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<BillingResponse> {
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<BillingResponse> {
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<BillingResponse> {
// AbacatePay usa o endpoint /billing/{id} para todos os tipos de billing
const response = await fetch(`${this.config.baseUrl}/billing/${pixId}`, {
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
`Failed to get PIX status: ${errorData.message || response.statusText}`
);
}
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
return result.data;
}
verifyWebhookSignature(payload: string, signature: string): boolean {
// 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 };
+47
View File
@@ -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<string, PaymentStatus>();
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);
+42
View File
@@ -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;
}
+169
View File
@@ -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<string, RateLimitEntry>();
// Configuração de rate limits por endpoint
const RATE_LIMITS: Record<string, RateLimitConfig> = {
"/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(),
},
}
);
}
+208
View File
@@ -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: [email protected] -> 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);
},
};
+145
View File
@@ -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<typeof createDonationSchema>;
export type VerifyStatusInput = z.infer<typeof verifyStatusSchema>;
export type VerifyBillingInput = z.infer<typeof verifyBillingSchema>;
+85
View File
@@ -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, "");
}
+138 -1
View File
@@ -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": "[email protected]",
"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"
}
}
}
+138 -1
View File
@@ -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": "[email protected]",
"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"
}
}
}
+22
View File
@@ -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);
+9 -1
View File
@@ -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"
+188
View File
@@ -35,6 +35,12 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@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([email protected])
react-qr-code:
specifier: ^2.0.18
version: 2.0.18([email protected])
recharts:
specifier: ^3.3.0
version: 3.3.0(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
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/[email protected])
'@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/[email protected]':
resolution: {integrity: sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==}
'@types/[email protected]':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/[email protected]':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -927,9 +954,15 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==}
'@types/[email protected]':
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
'@types/[email protected]':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/[email protected]':
resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==}
[email protected]:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@@ -937,6 +970,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
[email protected]:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
[email protected]:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -1003,6 +1039,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
[email protected]:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1010,13 +1050,34 @@ packages:
[email protected]:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
[email protected]:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
[email protected]:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
[email protected]:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
[email protected]:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
[email protected]:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
[email protected]:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==}
[email protected]:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@@ -1041,6 +1102,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
[email protected]:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
[email protected]:
resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==}
@@ -1051,10 +1115,17 @@ packages:
[email protected]:
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
[email protected]:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
[email protected]:
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'}
[email protected]:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-armkCAqQvO62EIX4Hq7hqX/q11WSZu0Jd23cnnqx0/49yIxGXyL/zyZfBxNN9YDx0ensPTb4L+DjTh3yQXUxtQ==}
peerDependencies:
@@ -1199,6 +1274,13 @@ packages:
sass:
optional: true
[email protected]:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
[email protected]:
resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==}
@@ -1213,14 +1295,28 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
[email protected]:
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
[email protected]:
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
peerDependencies:
react: ^19.1.0
[email protected]:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
[email protected]:
resolution: {integrity: sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==}
[email protected]:
resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==}
peerDependencies:
react: '*'
[email protected]:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -1286,6 +1382,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
[email protected]:
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
[email protected]:
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
[email protected]:
resolution: {integrity: sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==}
engines: {node: '>= 0.10'}
[email protected]:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
[email protected]:
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
snapshots:
'@alloc/[email protected]': {}
@@ -2072,6 +2178,8 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.15
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': {}
@@ -2108,14 +2216,22 @@ snapshots:
dependencies:
csstype: 3.1.3
'@types/[email protected]':
dependencies:
htmlparser2: 8.0.2
'@types/[email protected]': {}
'@types/[email protected]': {}
[email protected]:
dependencies:
tslib: 2.8.1
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
clsx: 2.1.1
@@ -2172,17 +2288,41 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
[email protected]: {}
[email protected]:
dependencies:
domelementtype: 2.3.0
[email protected]:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
[email protected]:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]([email protected]([email protected]))([email protected]):
@@ -2198,6 +2338,13 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
[email protected]: {}
[email protected]: {}
@@ -2209,8 +2356,12 @@ snapshots:
'@formatjs/icu-messageformat-parser': 2.11.4
tslib: 2.8.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
optional: true
@@ -2260,6 +2411,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
[email protected]:
dependencies:
js-tokens: 4.0.0
[email protected]([email protected]):
dependencies:
react: 19.1.0
@@ -2324,6 +2479,10 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2340,13 +2499,29 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
[email protected]:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
[email protected]: {}
[email protected]([email protected]):
dependencies:
react: 19.1.0
scheduler: 0.26.0
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
prop-types: 15.8.1
qr.js: 0.0.0
react: 19.1.0
[email protected](@types/[email protected])([email protected])([email protected]):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -2413,6 +2588,15 @@ snapshots:
[email protected]: {}
[email protected]:
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
[email protected]: {}
[email protected]:
@@ -2497,6 +2681,8 @@ snapshots:
dependencies:
react: 19.1.0
[email protected]: {}
[email protected]:
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
[email protected]: {}
+97
View File
@@ -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;
}