27 files changed
+3418
-5
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Generated
+188
@@ -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]: {}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user