Compare commits
28
Commits
No files matched your search
@@ -39,3 +39,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/iconScript/data
|
||||||
|
/data/images
|
||||||
@@ -1,36 +1,105 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
<div align="center">
|
||||||
|
|
||||||
## Getting Started
|
<img src="assets/readme/hero.svg" width="100%" alt="Tibia Trade: marketplace de itens de Tibia por servidor, com um anúncio em leilão, meios de pagamento aceitos e os botões Dar Lance e Intenção de compra">
|
||||||
|
|
||||||
First, run the development server:
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
```bash
|
[Plano de melhorias](docs/PLANO_MELHORIAS_MARKETPLACE.md) · [Configurar o AbacatePay](ABACATEPAY_SETUP.md) · [Issues](https://git.evandro.dev.br/evandro/pagodera/issues)
|
||||||
npm run dev
|
|
||||||
# or
|
</div>
|
||||||
yarn dev
|
|
||||||
# or
|
O catálogo de itens vem no repositório (`data/tibiawiki.db`, SQLite com dados do TibiaWiki BR). Lido com o SQLite embutido do Node 22.13+, sem instalar nada:
|
||||||
pnpm dev
|
|
||||||
# or
|
```console
|
||||||
bun dev
|
$ node --no-warnings -e "const db = new (require('node:sqlite').DatabaseSync)('data/tibiawiki.db', { readOnly: true }); const t = db.prepare(\"select name from sqlite_master where type = 'table' and name not like 'sqlite_%'\").all(); const n = t.reduce((s, { name }) => s + db.prepare('select count(*) as c from ' + name).get().c, 0); console.log(t.length + ' categorias, ' + n + ' itens'); console.table(db.prepare('select nome, lvl, voc, peso from espadas order by lvl desc limit 3').all())"
|
||||||
|
28 categorias, 2575 itens
|
||||||
|
┌─────────┬────────────────────────┬─────┬───────────┬──────┐
|
||||||
|
│ (index) │ nome │ lvl │ voc │ peso │
|
||||||
|
├─────────┼────────────────────────┼─────┼───────────┼──────┤
|
||||||
|
│ 0 │ 'Grand Sanguine Blade' │ 600 │ 'Knights' │ 6000 │
|
||||||
|
│ 1 │ 'Grand Sanguine Razor' │ 600 │ 'Knights' │ 7900 │
|
||||||
|
│ 2 │ 'Sanguine Blade' │ 600 │ 'Knights' │ 6100 │
|
||||||
|
└─────────┴────────────────────────┴─────┴───────────┴──────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## O que é
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
Tibia Trade (repositório `pagodera`) é um marketplace web para comprar, vender e leiloar itens de Tibia, separado por servidor. A interface tem português e inglês (`next-intl`, `messages/pt.json` e `messages/en.json`).
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| Página | O que faz |
|
||||||
|
|---|---|
|
||||||
|
| `/market` | Anúncios de venda por servidor, leilões com lances, intenções de compra ("Minhas intenções") e chat com o vendedor |
|
||||||
|
| `/market/analytics` | Impressões e cliques dos seus anúncios |
|
||||||
|
| `/library` | Catálogo de itens por categoria |
|
||||||
|
| `/apoiar` | Doação por PIX (QR code gerado pelo AbacatePay) |
|
||||||
|
| `/admin` | Cadastro de itens e categorias e gestão de planos de assinatura |
|
||||||
|
| `/precos`, `/sobre` | Ainda mostram "em construção" |
|
||||||
|
|
||||||
## Learn More
|
## Por que usar
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
- **Preço no meio que o jogador usa**: cada anúncio aceita PIX, Tibia Coins, gold coins ou troca.
|
||||||
|
- **Leilão de verdade**: anúncios podem ter prazo (`auction_ends_at`) e incremento mínimo de lance; cada lance gera notificação.
|
||||||
|
- **Negociação registrada**: o comprador abre uma intenção de compra, que vira um chat em tempo real com o vendedor (Socket.IO no servidor customizado `server.ts`, mensagens no MongoDB); o vendedor marca o anúncio como vendido.
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
Quando não usar: se você precisa de pagamento garantido entre comprador e vendedor. O escrow existe só no modelo de dados (status `in_escrow`, tabela `Transaction`, taxa de 2%); nenhuma rota movimenta dinheiro, e o AbacatePay só é usado para doações. Também não serve para quem quer subir algo leve: o app precisa de PostgreSQL, Redis e MongoDB.
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
## Como funciona
|
||||||
|
|
||||||
## Deploy on Vercel
|
1. `server.ts` sobe o Next.js 15 (App Router) e o Socket.IO no mesmo servidor HTTP.
|
||||||
|
2. As rotas em `app/api/` cuidam de autenticação (JWT com refresh, senhas com bcrypt), anúncios, lances, intenções de compra, chats, notificações, servidores, assinaturas e doações.
|
||||||
|
3. PostgreSQL guarda usuários, anúncios, lances e intenções (Prisma em `prisma/schema.prisma` e SQL puro em `lib/db/migrations/`); Redis atende autenticação, Socket.IO e controle de uso da IA; MongoDB guarda chats e mensagens.
|
||||||
|
4. O catálogo de itens sai de `data/tibiawiki.db` (com imagens em `data/images/`) e é importado por `lib/items/import-from-sqlite.ts`.
|
||||||
|
5. No admin, a análise de um item novo pode usar o Gemini (`GEMINI_API_KEY`); sem a chave, o app usa um fallback estático.
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
## Início rápido
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Requisitos: Node.js 18+ (o deploy em `nixpacks.toml` usa `nodejs_18`), pnpm, e instâncias de PostgreSQL, Redis e MongoDB. Não existe `.env.example`: crie o `.env` com as variáveis abaixo.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.evandro.dev.br/evandro/pagodera.git
|
||||||
|
cd pagodera
|
||||||
|
pnpm install # o postinstall roda `prisma generate`
|
||||||
|
pnpm db:migrate # aplica as migrações SQL no PostgreSQL (PG_DB)
|
||||||
|
pnpm db:seed # cadastra os servidores de Tibia
|
||||||
|
pnpm dev # tsx server.ts → http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Outros scripts: `pnpm build` (`next build --turbopack`), `pnpm start` (`NODE_ENV=production tsx server.ts`, sintaxe de shell POSIX), `pnpm lint` (Biome), `pnpm test` (`tests/run-tests.ts`), `pnpm db:test` (testa a conexão).
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
| Variável | Uso |
|
||||||
|
|---|---|
|
||||||
|
| `PG_DB` | Connection string do PostgreSQL para o pool `pg` e as migrações (obrigatória) |
|
||||||
|
| `DATABASE_URL_APP` | Connection string do PostgreSQL para o Prisma |
|
||||||
|
| `REDIS_DB` | Connection string do Redis (obrigatória) |
|
||||||
|
| `MONGO_URL` | Connection string do MongoDB, para chats (obrigatória) |
|
||||||
|
| `JWT_SECRET`, `JWT_REFRESH_SECRET` | Assinatura dos tokens de acesso e de refresh (obrigatórias) |
|
||||||
|
| `NEXT_PUBLIC_BASE_URL` | Origem aceita pelo Socket.IO (padrão `http://localhost:3000`) |
|
||||||
|
| `GEMINI_API_KEY` | Análise de itens no admin (opcional) |
|
||||||
|
| `ABACATE_APIKEY`, `ABACATE_TEST_APIKEY`, `ABACATEPAY_WEBHOOK_SECRET`, `ABACATEPAY_WHITELIST_IPS` | Doações por PIX e o webhook do AbacatePay (veja [`ABACATEPAY_SETUP.md`](ABACATEPAY_SETUP.md)) |
|
||||||
|
| `HOSTNAME`, `PORT` | Endereço do servidor (padrão `localhost:3000`) |
|
||||||
|
|
||||||
|
## Estado do projeto
|
||||||
|
|
||||||
|
Em desenvolvimento (`package.json` na versão `0.1.0`). O último commit no branch padrão (`develop`) é de 2025-11-22; o branch `wip/2026-10-20` guarda um snapshot de alterações ainda não finalizadas. O ambiente de desenvolvimento `tibiatrade.evandro.dev.br` não respondia com o app em 2026-10-24.
|
||||||
|
|
||||||
|
O que falta:
|
||||||
|
|
||||||
|
- Escrow e pagamento entre usuários (veja "Quando não usar").
|
||||||
|
- O webhook de doações ainda não persiste a doação nem envia e-mail (pendências marcadas em `app/api/webhooks/abacatepay/route.ts`).
|
||||||
|
- `/precos` e `/sobre` estão em construção.
|
||||||
|
- `pnpm test` cobre só a validação dos metadados de itens (3 testes); não há CI.
|
||||||
|
|
||||||
|
## Licença
|
||||||
|
|
||||||
|
Sem licença publicada: todos os direitos reservados.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<sub>Feito por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Resumo da Implementação de SEO On-Page
|
||||||
|
|
||||||
|
## ✅ Implementações Concluídas
|
||||||
|
|
||||||
|
### 1. Rebranding Completo
|
||||||
|
|
||||||
|
- ✅ Removidas TODAS as referências a "Pagodera"
|
||||||
|
- ✅ Substituído por "Tibia Trade" em todos os arquivos
|
||||||
|
- ✅ package.json atualizado com nome "tibia-trade"
|
||||||
|
- ✅ README.md reescrito com palavras-chave SEO
|
||||||
|
- ✅ Traduções PT/EN atualizadas
|
||||||
|
|
||||||
|
### 2. Metadados Otimizados
|
||||||
|
|
||||||
|
- ✅ Função `generateMetadata` dinâmica implementada para i18n
|
||||||
|
- ✅ Títulos otimizados com palavras-chave (≤60 caracteres)
|
||||||
|
- ✅ Meta descriptions envolventes (≤160 caracteres)
|
||||||
|
- ✅ Keywords estratégicas adicionadas:
|
||||||
|
- tibia marketplace
|
||||||
|
- tibia trade
|
||||||
|
- comprar itens tibia
|
||||||
|
- vender itens tibia
|
||||||
|
- tibia escrow
|
||||||
|
- tibia gold
|
||||||
|
- tibia coins
|
||||||
|
- tc tibia
|
||||||
|
- marketplace tibia seguro
|
||||||
|
- tibia bid system (única)
|
||||||
|
- tibia item escrow (única)
|
||||||
|
- tibia multi-server marketplace (única)
|
||||||
|
|
||||||
|
### 3. Open Graph & Twitter Cards
|
||||||
|
|
||||||
|
- ✅ Open Graph configurado (Facebook, LinkedIn, Discord)
|
||||||
|
- ✅ Twitter Cards configurado
|
||||||
|
- ✅ Site name: "Tibia Trade"
|
||||||
|
- ✅ Canônical URLs implementadas
|
||||||
|
- ⚠️ **AÇÃO NECESSÁRIA**: Criar imagem `public/og-image.png` (1200x630px)
|
||||||
|
- Conteúdo sugerido: Logo "Tibia Trade" + tagline "Marketplace Seguro Multi-Servidor"
|
||||||
|
- Formato: PNG otimizado
|
||||||
|
- Cores: Seguir identidade visual do site
|
||||||
|
|
||||||
|
### 4. Schema Markup JSON-LD
|
||||||
|
|
||||||
|
- ✅ Organization (Organização)
|
||||||
|
- ✅ WebSite com SearchAction
|
||||||
|
- ✅ FAQPage no componente de Escrow
|
||||||
|
- ✅ Breadcrumbs com Schema
|
||||||
|
|
||||||
|
### 5. Estrutura HTML Semântica
|
||||||
|
|
||||||
|
- ✅ H1 implementado no Hero Section
|
||||||
|
- ✅ H2 para seções principais ("Como Funciona", "Negocie com Segurança")
|
||||||
|
- ✅ H3 para subcategorias e steps
|
||||||
|
- ✅ Tag `<main>` com aria-label na homepage
|
||||||
|
- ✅ Breadcrumbs component criado (disponível para uso)
|
||||||
|
|
||||||
|
### 6. Otimização de Imagens
|
||||||
|
|
||||||
|
- ✅ Propriedade `alt` adicionada a todos os itens em `mock-data.ts`
|
||||||
|
- ✅ Configuração Next.js Image para WebP/AVIF
|
||||||
|
- ✅ Lazy loading configurado
|
||||||
|
- ✅ Device sizes otimizados (640, 750, 828, 1080, 1200, 1920)
|
||||||
|
|
||||||
|
### 7. Links Internos
|
||||||
|
|
||||||
|
- ✅ Links contextuais adicionados no componente "Como Funciona"
|
||||||
|
- ✅ Estrutura de silos preparada
|
||||||
|
|
||||||
|
### 8. Sitemap & Robots
|
||||||
|
|
||||||
|
- ✅ `app/sitemap.ts` criado (dinâmico, multi-idioma)
|
||||||
|
- ✅ `public/robots.txt` criado
|
||||||
|
- ✅ Change frequency configurada (daily/weekly)
|
||||||
|
- ✅ Prioridades definidas
|
||||||
|
|
||||||
|
### 9. Performance (Core Web Vitals)
|
||||||
|
|
||||||
|
- ✅ Compressão habilitada
|
||||||
|
- ✅ `poweredByHeader` removido
|
||||||
|
- ✅ Formats modernos (WebP, AVIF)
|
||||||
|
- ✅ Lazy loading de componentes pesados
|
||||||
|
|
||||||
|
## ⚠️ Ações Pendentes pelo Usuário
|
||||||
|
|
||||||
|
### 1. Criar Imagem Open Graph
|
||||||
|
|
||||||
|
**Arquivo**: `public/og-image.png`
|
||||||
|
**Dimensões**: 1200x630px
|
||||||
|
**Conteúdo sugerido**:
|
||||||
|
|
||||||
|
- Logo "Tibia Trade" (esquerda ou centro)
|
||||||
|
- Tagline: "Marketplace Seguro Multi-Servidor"
|
||||||
|
- Background com gradiente ou textura
|
||||||
|
- Cores alinhadas com tema do site
|
||||||
|
|
||||||
|
### 2. Criar Logos e Ícones
|
||||||
|
|
||||||
|
**Arquivos necessários**:
|
||||||
|
|
||||||
|
- `/public/logo.png` (para Schema Organization)
|
||||||
|
- `/public/icon.svg` (versão vetorizada do logo)
|
||||||
|
- `/public/apple-touch-icon.png` (para iOS, 180x180px)
|
||||||
|
|
||||||
|
### 3. Validar Implementação
|
||||||
|
|
||||||
|
Após deploy, validar com:
|
||||||
|
|
||||||
|
#### Google Tools:
|
||||||
|
|
||||||
|
1. **Google Search Console**: Adicionar sitemap e verificar indexação
|
||||||
|
2. **PageSpeed Insights**: Validar Core Web Vitals (LCP < 2.5s)
|
||||||
|
3. **Rich Results Test**: Validar Schema Markup
|
||||||
|
4. **Mobile-Friendly Test**: Confirmar responsividade
|
||||||
|
5. **Schema Markup Validator**: https://validator.schema.org/
|
||||||
|
|
||||||
|
#### Lighthouse CI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @lhci/cli
|
||||||
|
lhci autorun
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Checklist de SEO On-Page
|
||||||
|
|
||||||
|
### Conteúdo ✓
|
||||||
|
|
||||||
|
- [x] Título otimizado com palavra-chave principal no início
|
||||||
|
- [x] Meta description envolvente com CTA
|
||||||
|
- [x] Headings hierárquicos (H1-H3)
|
||||||
|
- [x] Primeiro parágrafo contém palavra-chave
|
||||||
|
- [x] Densidade natural de palavras-chave
|
||||||
|
- [x] Links internos estratégicos
|
||||||
|
|
||||||
|
### Estrutura Técnica ✓
|
||||||
|
|
||||||
|
- [x] URLs limpas e curtas
|
||||||
|
- [x] Breadcrumbs implementados
|
||||||
|
- [x] Schema Markup completo
|
||||||
|
- [x] HTML semântico
|
||||||
|
- [x] Favicons configurados
|
||||||
|
|
||||||
|
### Imagens ✓
|
||||||
|
|
||||||
|
- [x] ALT text descritivo em TODOS os itens
|
||||||
|
- [x] Formatos modernos (WebP/AVIF)
|
||||||
|
- [x] Lazy loading implementado
|
||||||
|
- [x] Tamanhos responsivos
|
||||||
|
|
||||||
|
### Performance ✓
|
||||||
|
|
||||||
|
- [x] Core Web Vitals configurado
|
||||||
|
- [x] Compressão ativada
|
||||||
|
- [x] Lazy loading de componentes pesados
|
||||||
|
- [x] Meta headers otimizados
|
||||||
|
|
||||||
|
### EEAT (Expertise, Experience, Authoritativeness, Trust) ✓
|
||||||
|
|
||||||
|
- [x] Informação de autor (Tibia Trade Team)
|
||||||
|
- [x] Links para Discord
|
||||||
|
- [x] Transparência de custos
|
||||||
|
- [x] Política clara de uso
|
||||||
|
|
||||||
|
## 🎯 Palavras-Chave Implementadas
|
||||||
|
|
||||||
|
### Primárias (Alta Intenção)
|
||||||
|
|
||||||
|
- tibia marketplace
|
||||||
|
- tibia trade
|
||||||
|
- comprar itens tibia
|
||||||
|
- vender itens tibia
|
||||||
|
- tibia escrow
|
||||||
|
|
||||||
|
### Secundárias (Long-tail)
|
||||||
|
|
||||||
|
- marketplace tibia seguro
|
||||||
|
- tibia gold trade
|
||||||
|
- tibia coins marketplace
|
||||||
|
- comprar tc tibia
|
||||||
|
- tibia armadura rara
|
||||||
|
|
||||||
|
### Nicho (Baixa Concorrência)
|
||||||
|
|
||||||
|
- tibia bid system
|
||||||
|
- tibia item escrow
|
||||||
|
- tibia multi-server marketplace
|
||||||
|
|
||||||
|
### LSI (Semânticas)
|
||||||
|
|
||||||
|
- tibia items, tibia server, tibia weapons, tibia armor
|
||||||
|
- tibia secure trading, tibia safe marketplace
|
||||||
|
- gold pieces, tibia coins, tc
|
||||||
|
- knight, paladin, sorcerer, druid
|
||||||
|
|
||||||
|
## 📈 Próximos Passos Recomendados
|
||||||
|
|
||||||
|
1. **Criar conteúdo rico na página `/sobre`**
|
||||||
|
|
||||||
|
- História do projeto
|
||||||
|
- Equipe com perfis
|
||||||
|
- Certificações de segurança
|
||||||
|
|
||||||
|
2. **Implementar página de blog/notícias**
|
||||||
|
|
||||||
|
- Artigos sobre trading
|
||||||
|
- Guias de segurança
|
||||||
|
- Atualizações do marketplace
|
||||||
|
|
||||||
|
3. **Adicionar reviews/testimonials**
|
||||||
|
|
||||||
|
- Avaliações de usuários
|
||||||
|
- Casos de sucesso
|
||||||
|
|
||||||
|
4. **Configurar Google Analytics & Search Console**
|
||||||
|
|
||||||
|
- Monitorar tráfego
|
||||||
|
- Rastrear palavras-chave
|
||||||
|
- Analisar performance
|
||||||
|
|
||||||
|
5. **Implementar multi-idioma completo**
|
||||||
|
- Espanhol (opcional)
|
||||||
|
- Localização completa
|
||||||
|
|
||||||
|
## 📝 Arquivos Modificados/Criados
|
||||||
|
|
||||||
|
### Configuração (3)
|
||||||
|
|
||||||
|
- ✓ package.json
|
||||||
|
- ✓ README.md
|
||||||
|
- ✓ next.config.ts
|
||||||
|
|
||||||
|
### Tradução (2)
|
||||||
|
|
||||||
|
- ✓ messages/pt.json
|
||||||
|
- ✓ messages/en.json
|
||||||
|
|
||||||
|
### Layout (2)
|
||||||
|
|
||||||
|
- ✓ app/[locale]/layout.tsx
|
||||||
|
- ✓ app/[locale]/page.tsx
|
||||||
|
|
||||||
|
### Páginas (4)
|
||||||
|
|
||||||
|
- ✓ app/[locale]/market/page.tsx
|
||||||
|
- ✓ app/[locale]/sobre/page.tsx
|
||||||
|
- ✓ app/[locale]/precos/page.tsx
|
||||||
|
- ✓ app/[locale]/apoiar/page.tsx
|
||||||
|
|
||||||
|
### Componentes (6)
|
||||||
|
|
||||||
|
- ✓ components/Header.tsx
|
||||||
|
- ✓ components/share-button.tsx
|
||||||
|
- ✓ components/sections/hero-section.tsx
|
||||||
|
- ✓ components/sections/escrow-explanation.tsx
|
||||||
|
- ✓ app/[locale]/apoiar/obrigado/page.tsx
|
||||||
|
- ✓ components/breadcrumbs.tsx (NOVO)
|
||||||
|
|
||||||
|
### SEO (3)
|
||||||
|
|
||||||
|
- ✓ app/sitemap.ts (NOVO)
|
||||||
|
- ✓ public/robots.txt (NOVO)
|
||||||
|
- ⚠️ public/og-image.png (CRIAR)
|
||||||
|
|
||||||
|
### Otimização (2)
|
||||||
|
|
||||||
|
- ✓ lib/mock-data.ts
|
||||||
|
- ✓ types/item.ts
|
||||||
|
|
||||||
|
**Total**: 22 arquivos modificados/criados
|
||||||
|
|
||||||
|
## 🎉 Resultados Esperados
|
||||||
|
|
||||||
|
Com essas implementações, o site deve:
|
||||||
|
|
||||||
|
1. **Rankear melhor** para palavras-chave estratégicas
|
||||||
|
2. **Aparecer em rich snippets** do Google (FAQ, Organization, SearchAction)
|
||||||
|
3. **Ter melhor CTR** em SERPs (Open Graph + Twitter Cards)
|
||||||
|
4. **Performance otimizada** (Core Web Vitals < limites)
|
||||||
|
5. **Experiência de usuário superior** (Acessibilidade, SEMÂNTICA)
|
||||||
|
6. **Indexação eficiente** (Sitemap + Robots.txt)
|
||||||
|
7. **SEO visual** (Imagens com ALT descritivo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Data de Implementação**: $(date)
|
||||||
|
**Status**: ✅ Completo (exceto imagem og-image.png)
|
||||||
|
**Próxima Revisão**: Após deploy e validação com Google Search Console
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AdminItemCreatePage } from "@/components/admin/items/AdminItemCreatePage";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import { searchAdminItems } from "@/lib/prisma/queries/items";
|
||||||
|
import { getActiveServers } from "@/lib/prisma/queries/servers";
|
||||||
|
import { getUserById } from "@/lib/prisma/queries/users";
|
||||||
|
import { getSession } from "@/lib/redis/session";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return {
|
||||||
|
title: "Novo item | Painel Administrativo",
|
||||||
|
description: "Cadastre um novo item unificado no catálogo administrativo.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminNewItemPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = cookieStore.get("sessionId")?.value;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
redirect(`/${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
redirect(`/${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUser = await getUserById(session.userId);
|
||||||
|
if (!adminUser || adminUser.user_id !== 1) {
|
||||||
|
redirect(`/${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [initialItems, activeServers] = await Promise.all([
|
||||||
|
searchAdminItems({ page: 1, pageSize: 1 }),
|
||||||
|
getActiveServers(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<main className="container mx-auto px-4 py-10">
|
||||||
|
<AdminItemCreatePage
|
||||||
|
locale={locale}
|
||||||
|
categories={initialItems.categories ?? []}
|
||||||
|
serverOptions={(activeServers ?? []).map((s) => s.server_name)}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AdminPanelTabs } from "@/components/admin/AdminPanelTabs";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import { searchAdminItems } from "@/lib/prisma/queries/items";
|
||||||
|
import { getAllSubscriptionTiers } from "@/lib/prisma/queries/subscription-tiers";
|
||||||
|
import { getUserById } from "@/lib/prisma/queries/users";
|
||||||
|
import { getSession } from "@/lib/redis/session";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return {
|
||||||
|
title: "Painel Administrativo",
|
||||||
|
description:
|
||||||
|
"Gerencie assinaturas, permissões e catalogação de itens exclusivos do Tibia Trade.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams:
|
||||||
|
| Promise<Record<string, string | string[]>>
|
||||||
|
| Record<string, string | string[]>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const resolvedSearchParams =
|
||||||
|
searchParams instanceof Promise ? await searchParams : searchParams;
|
||||||
|
const tabParam = resolvedSearchParams?.tab;
|
||||||
|
const initialTab =
|
||||||
|
typeof tabParam === "string" &&
|
||||||
|
["overview", "subscriptions", "items"].includes(tabParam)
|
||||||
|
? (tabParam as "overview" | "subscriptions" | "items")
|
||||||
|
: "overview";
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = cookieStore.get("sessionId")?.value;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
redirect(`/${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
if (!session || session.userId !== 1) {
|
||||||
|
redirect(`/${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tiers, adminUser, initialItems] = await Promise.all([
|
||||||
|
getAllSubscriptionTiers(true),
|
||||||
|
getUserById(session.userId),
|
||||||
|
searchAdminItems({ page: 1, pageSize: 50 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const normalizedTiers = tiers.map((tier) => {
|
||||||
|
const featureList = (tier.features ?? []) as Array<Record<string, any>>;
|
||||||
|
return {
|
||||||
|
...tier,
|
||||||
|
price_monthly: tier.price_monthly ? Number(tier.price_monthly) : null,
|
||||||
|
price_yearly: tier.price_yearly ? Number(tier.price_yearly) : null,
|
||||||
|
created_at:
|
||||||
|
tier.created_at instanceof Date
|
||||||
|
? tier.created_at.toISOString()
|
||||||
|
: tier.created_at,
|
||||||
|
updated_at:
|
||||||
|
tier.updated_at instanceof Date
|
||||||
|
? tier.updated_at.toISOString()
|
||||||
|
: tier.updated_at,
|
||||||
|
features: featureList.map((feature) => ({
|
||||||
|
...feature,
|
||||||
|
created_at:
|
||||||
|
feature.created_at instanceof Date
|
||||||
|
? feature.created_at.toISOString()
|
||||||
|
: (feature.created_at ?? null),
|
||||||
|
updated_at:
|
||||||
|
feature.updated_at instanceof Date
|
||||||
|
? feature.updated_at.toISOString()
|
||||||
|
: (feature.updated_at ?? null),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<Header />
|
||||||
|
<main className="container mx-auto px-4 py-10 space-y-8">
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
Painel administrativo
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground max-w-2xl">
|
||||||
|
Centralize a gestão de planos, permissões e o catálogo de itens.
|
||||||
|
Utilize as abas para acessar cada área temática.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AdminPanelTabs
|
||||||
|
adminUser={adminUser}
|
||||||
|
initialTiers={normalizedTiers}
|
||||||
|
itemsData={initialItems}
|
||||||
|
locale={locale}
|
||||||
|
initialTab={initialTab}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return {
|
||||||
|
title: "Assinaturas e Permissões",
|
||||||
|
description: "Redirecionando para o painel administrativo centralizado.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminSubscriptionsRedirect({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
redirect(`/${locale}/admin?tab=subscriptions`);
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
import Header from "@/components/Header";
|
import { Heart, Home, Share2 } from "lucide-react";
|
||||||
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";
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import { BlurFade } from "@/components/ui/blur-fade";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export default function ThankYouPage() {
|
export default function ThankYouPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -54,8 +54,8 @@ export default function ThankYouPage() {
|
|||||||
<div className="p-8 rounded-2xl border border-border/50 bg-card backdrop-blur-lg mb-8">
|
<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">
|
<p className="text-lg leading-relaxed text-muted-foreground">
|
||||||
Sua generosidade nos ajuda a continuar desenvolvendo e mantendo
|
Sua generosidade nos ajuda a continuar desenvolvendo e mantendo
|
||||||
o Tibia Trade Pagodera. Cada contribuição faz toda a diferença
|
o Tibia Trade. Cada contribuição faz toda a diferença para nossa
|
||||||
para nossa comunidade!
|
comunidade!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,8 +86,8 @@ export default function ThankYouPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (typeof navigator !== "undefined" && navigator.share) {
|
if (typeof navigator !== "undefined" && navigator.share) {
|
||||||
navigator.share({
|
navigator.share({
|
||||||
title: "Tibia Trade Pagodera",
|
title: "Tibia Trade",
|
||||||
text: "Acabei de fazer uma doação para o Tibia Trade Pagodera! Ajude também!",
|
text: "Acabei de fazer uma doação para o Tibia Trade! Ajude também!",
|
||||||
url: window.location.origin,
|
url: window.location.origin,
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
@@ -108,8 +108,8 @@ export default function ThankYouPage() {
|
|||||||
<div className="mt-12 p-6 rounded-xl bg-muted/30 border border-border/30">
|
<div className="mt-12 p-6 rounded-xl bg-muted/30 border border-border/30">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
💡 <strong>Dica:</strong> Compartilhe nossa plataforma com seus
|
💡 <strong>Dica:</strong> Compartilhe nossa plataforma com seus
|
||||||
amigos! Quanto mais pessoas usarem o Tibia Trade Pagodera, mais
|
amigos! Quanto mais pessoas usarem o Tibia Trade, mais forte
|
||||||
forte nossa comunidade fica.
|
nossa comunidade fica.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,46 @@
|
|||||||
import Header from "@/components/Header";
|
import { Heart } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { DonationCard } from "@/components/donation-card";
|
import { DonationCard } from "@/components/donation-card";
|
||||||
import { BlurFade } from "@/components/ui/blur-fade";
|
import Header from "@/components/Header";
|
||||||
import { NumberTicker } from "@/components/ui/number-ticker";
|
import { ShareButton } from "@/components/share-button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Heart } from "lucide-react";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { BlurFade } from "@/components/ui/blur-fade";
|
||||||
import Link from "next/link";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ShareButton } from "@/components/share-button";
|
import { NumberTicker } from "@/components/ui/number-ticker";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: "donation" });
|
|
||||||
|
const titles = {
|
||||||
|
pt: "Apoie o Projeto - Doações Voluntárias",
|
||||||
|
en: "Support the Project - Voluntary Donations",
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptions = {
|
||||||
|
pt: "Ajude a manter o Tibia Trade funcionando com uma doação voluntária via PIX. Transparência total dos custos e investimentos no projeto.",
|
||||||
|
en: "Help keep Tibia Trade running with a voluntary donation via PIX. Total transparency of costs and project investments.",
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: t("title"),
|
title: titles[locale as keyof typeof titles],
|
||||||
description: t("subtitle"),
|
description: descriptions[locale as keyof typeof descriptions],
|
||||||
|
keywords: [
|
||||||
|
"doação tibia trade",
|
||||||
|
"apoiar tibia trade",
|
||||||
|
"ajudar tibia trade",
|
||||||
|
"transparência tibia trade",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +59,7 @@ export default async function SupportPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Custos mensais REAIS
|
// Custos mensais REAIS
|
||||||
const monthlyCosts = {
|
const _monthlyCosts = {
|
||||||
vps: 69, // R$ 69/mês
|
vps: 69, // R$ 69/mês
|
||||||
devHours: 40, // média horas/mês
|
devHours: 40, // média horas/mês
|
||||||
devRate: 50, // R$ 50/hora
|
devRate: 50, // R$ 50/hora
|
||||||
|
|||||||
+142
-32
@@ -1,25 +1,100 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono, Nunito_Sans } from "next/font/google";
|
import Script from "next/script";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import "../globals.css";
|
import { QueryProvider } from "@/lib/providers/query-provider";
|
||||||
|
|
||||||
const nunitoSans = Nunito_Sans({
|
export async function generateMetadata({
|
||||||
variable: "--font-nunito-sans",
|
params,
|
||||||
subsets: ["latin"],
|
}: {
|
||||||
weight: ["200", "300", "400", "500", "600", "700", "800", "900"],
|
params: Promise<{ locale: string }>;
|
||||||
});
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const titles = {
|
||||||
variable: "--font-geist-mono",
|
pt: "Tibia Trade - Marketplace Seguro para Compra e Venda de Itens",
|
||||||
subsets: ["latin"],
|
en: "Tibia Trade - Secure Marketplace for Buying and Selling Items",
|
||||||
});
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const descriptions = {
|
||||||
title: "Pagodera Trade",
|
pt: "Marketplace oficial multi-servidor para negociar itens de Tibia com segurança total. Sistema escrow protege 100% das transações. Compre e venda gold, TC, armaduras raras e equipamentos.",
|
||||||
description: "Markeplace servidor pagodera",
|
en: "Official multi-server marketplace to trade Tibia items with total security. Escrow system protects 100% of transactions. Buy and sell gold, TC, rare armors and equipment.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
default: titles[locale as keyof typeof titles],
|
||||||
|
template: `%s | Tibia Trade`,
|
||||||
|
},
|
||||||
|
description: descriptions[locale as keyof typeof descriptions],
|
||||||
|
keywords: [
|
||||||
|
"tibia marketplace",
|
||||||
|
"tibia trade",
|
||||||
|
"comprar itens tibia",
|
||||||
|
"vender itens tibia",
|
||||||
|
"tibia escrow",
|
||||||
|
"tibia gold",
|
||||||
|
"tibia coins",
|
||||||
|
"tc tibia",
|
||||||
|
"marketplace tibia seguro",
|
||||||
|
"tibia bid system",
|
||||||
|
"tibia item escrow",
|
||||||
|
"tibia multi-server marketplace",
|
||||||
|
],
|
||||||
|
authors: [{ name: "Tibia Trade Team" }],
|
||||||
|
creator: "Tibia Trade",
|
||||||
|
publisher: "Tibia Trade",
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-video-preview": -1,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: `https://tibiatrade.evandro.dev.br/${locale}`,
|
||||||
|
languages: {
|
||||||
|
"pt-BR": `https://tibiatrade.evandro.dev.br/pt`,
|
||||||
|
"en-US": `https://tibiatrade.evandro.dev.br/en`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: locale === "pt" ? "pt_BR" : "en_US",
|
||||||
|
url: `https://tibiatrade.evandro.dev.br/${locale}`,
|
||||||
|
title: titles[locale as keyof typeof titles],
|
||||||
|
description: descriptions[locale as keyof typeof descriptions],
|
||||||
|
siteName: "Tibia Trade",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/og-image.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "Tibia Trade - Marketplace Seguro",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: titles[locale as keyof typeof titles],
|
||||||
|
description: descriptions[locale as keyof typeof descriptions],
|
||||||
|
images: ["/og-image.png"],
|
||||||
|
creator: "@tibiatrade",
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/favicon.ico", type: "image/x-icon" },
|
||||||
|
{ url: "/icon.svg", type: "image/svg+xml" },
|
||||||
|
],
|
||||||
|
apple: "/apple-touch-icon.png",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
@@ -29,24 +104,59 @@ export default async function LocaleLayout({
|
|||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
const baseUrl = "https://tibiatrade.evandro.dev.br";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<QueryProvider>
|
||||||
<body
|
<NextIntlClientProvider messages={messages}>
|
||||||
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
|
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||||
>
|
<Script
|
||||||
<NextIntlClientProvider messages={messages}>
|
id="structured-data-organization"
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
type="application/ld+json"
|
||||||
{children}
|
strategy="afterInteractive"
|
||||||
</ThemeProvider>
|
>
|
||||||
</NextIntlClientProvider>
|
{JSON.stringify({
|
||||||
</body>
|
"@context": "https://schema.org",
|
||||||
</html>
|
"@type": "Organization",
|
||||||
|
name: "Tibia Trade",
|
||||||
|
url: baseUrl,
|
||||||
|
logo: `${baseUrl}/logo.png`,
|
||||||
|
description:
|
||||||
|
"Marketplace multi-servidor seguro para negociação de itens de Tibia",
|
||||||
|
sameAs: ["https://discord.gg/tibiatrade"],
|
||||||
|
contactPoint: {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
contactType: "Customer Support",
|
||||||
|
availableLanguage: ["Portuguese", "English"],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</Script>
|
||||||
|
<Script
|
||||||
|
id="structured-data-website"
|
||||||
|
type="application/ld+json"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
>
|
||||||
|
{JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
name: "Tibia Trade",
|
||||||
|
url: baseUrl,
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: {
|
||||||
|
"@type": "EntryPoint",
|
||||||
|
urlTemplate: `${baseUrl}/market?q={search_term_string}`,
|
||||||
|
},
|
||||||
|
"query-input": "required name=search_term_string",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</Script>
|
||||||
|
<div data-locale={locale}>{children}</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</QueryProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { ItemCard } from "@/components/item-card";
|
||||||
|
import { MarketplaceFilters } from "@/components/marketplace-filters";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
image?: string;
|
||||||
|
rarity?: string;
|
||||||
|
npcPrice?: number;
|
||||||
|
price?: { buy: number; sell: number };
|
||||||
|
priceHistory?: { highest: number; lowest: number; average: number };
|
||||||
|
level?: number;
|
||||||
|
weight?: number;
|
||||||
|
slots?: number;
|
||||||
|
skill?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InitialData {
|
||||||
|
items: Item[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LibraryClient({ initial }: { initial: InitialData }) {
|
||||||
|
const [items, setItems] = useState<Item[]>(initial.items);
|
||||||
|
const [total, setTotal] = useState<number>(initial.total);
|
||||||
|
const [page, setPage] = useState<number>(initial.page || 1);
|
||||||
|
const [pageSize] = useState<number>(initial.pageSize || 24);
|
||||||
|
const [totalPages, setTotalPages] = useState<number>(initial.totalPages || 1);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState<string>("");
|
||||||
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
|
const [rarities, setRarities] = useState<string[]>([]);
|
||||||
|
const [sort, setSort] = useState<string>("name");
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<
|
||||||
|
{ value: string; label: string }[] | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [vocation, setVocation] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const debounceRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const searchParams = useMemo(() => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (query) p.set("q", query);
|
||||||
|
if (categories.length > 0) p.set("category", categories.join(","));
|
||||||
|
if (rarities.length > 0) p.set("rarity", rarities.join(","));
|
||||||
|
if (sort) p.set("sort", sort);
|
||||||
|
if (vocation) p.set("voc", vocation);
|
||||||
|
p.set("page", String(page));
|
||||||
|
p.set("pageSize", String(pageSize));
|
||||||
|
return p;
|
||||||
|
}, [query, categories, rarities, sort, page, pageSize, vocation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Debounce para evitar chamadas excessivas
|
||||||
|
window.clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = window.setTimeout(async () => {
|
||||||
|
const res = await fetch(`/api/items?${searchParams.toString()}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setItems(data.items);
|
||||||
|
setTotal(data.total);
|
||||||
|
setTotalPages(data.totalPages);
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearTimeout(debounceRef.current);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Carregar categorias dinâmicas a partir das tabelas do banco
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/items/categories");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data.categories)) {
|
||||||
|
const opts = (data.categories as string[]).map((c) => ({
|
||||||
|
value: c,
|
||||||
|
label: c.charAt(0).toUpperCase() + c.slice(1),
|
||||||
|
}));
|
||||||
|
setCategoryOptions(opts);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSearch = (q: string) => {
|
||||||
|
setPage(1);
|
||||||
|
setQuery(q);
|
||||||
|
};
|
||||||
|
const onFilter = (f: {
|
||||||
|
rarity: string[];
|
||||||
|
category: string[];
|
||||||
|
priceRange?: { min: number; max: number };
|
||||||
|
vocation?: string | null;
|
||||||
|
}) => {
|
||||||
|
setPage(1);
|
||||||
|
setCategories(f.category || []);
|
||||||
|
setRarities(f.rarity || []);
|
||||||
|
setVocation(f.vocation ?? null);
|
||||||
|
};
|
||||||
|
const onSort = (s: string) => {
|
||||||
|
setPage(1);
|
||||||
|
setSort(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<MarketplaceFilters
|
||||||
|
onSearch={onSearch}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onSort={onSort}
|
||||||
|
totalItems={total}
|
||||||
|
showRarity={false}
|
||||||
|
categoryOptionsOverride={categoryOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="grid gap-6 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 grid-flow-row-dense">
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const anyItem = item as any;
|
||||||
|
// Normalização de categoria para os ícones do ItemCard
|
||||||
|
const normalizeCategory = (c: string | undefined): string => {
|
||||||
|
const v = (c || "").toLowerCase();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
escudos: "shield",
|
||||||
|
armaduras: "armor",
|
||||||
|
capacetes: "helmet",
|
||||||
|
calcas: "legs",
|
||||||
|
botas: "boots",
|
||||||
|
amuletos: "amulet",
|
||||||
|
aneis: "ring",
|
||||||
|
runas: "rune",
|
||||||
|
ferramentas: "tool",
|
||||||
|
municao: "ammunition",
|
||||||
|
aljavas: "ammunition",
|
||||||
|
distancia: "weapon",
|
||||||
|
espadas: "weapon",
|
||||||
|
machados: "weapon",
|
||||||
|
clavas: "weapon",
|
||||||
|
punhos: "weapon",
|
||||||
|
wands: "weapon",
|
||||||
|
rods: "weapon",
|
||||||
|
spellbooks: "other",
|
||||||
|
chaves: "other",
|
||||||
|
comidas: "other",
|
||||||
|
decoracoes: "other",
|
||||||
|
liquidos: "other",
|
||||||
|
plantas: "other",
|
||||||
|
recipientes: "other",
|
||||||
|
trofeus: "other",
|
||||||
|
};
|
||||||
|
return map[v] || v || "other";
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryPt =
|
||||||
|
typeof anyItem.categoria === "string"
|
||||||
|
? anyItem.categoria
|
||||||
|
: undefined;
|
||||||
|
const rawCategory = item.category ?? anyItem.category ?? categoryPt;
|
||||||
|
const category = normalizeCategory(rawCategory || "other");
|
||||||
|
|
||||||
|
const idSafe =
|
||||||
|
typeof item.id === "string" || typeof item.id === "number"
|
||||||
|
? String(item.id)
|
||||||
|
: typeof anyItem.item_id === "number"
|
||||||
|
? String(anyItem.item_id)
|
||||||
|
: typeof anyItem.id === "number"
|
||||||
|
? String(anyItem.id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const name =
|
||||||
|
(typeof item.name === "string" && item.name
|
||||||
|
? item.name
|
||||||
|
: typeof anyItem.item_name === "string" && anyItem.item_name
|
||||||
|
? anyItem.item_name
|
||||||
|
: typeof anyItem.nome === "string" && anyItem.nome
|
||||||
|
? anyItem.nome
|
||||||
|
: idSafe) || "Sem nome";
|
||||||
|
|
||||||
|
const image = item.image ?? anyItem.image_url ?? "";
|
||||||
|
|
||||||
|
// Campos numéricos normalizados
|
||||||
|
const level: number | undefined =
|
||||||
|
typeof item.level === "number"
|
||||||
|
? item.level
|
||||||
|
: typeof anyItem.nivel === "number"
|
||||||
|
? anyItem.nivel
|
||||||
|
: typeof anyItem.lvl === "number"
|
||||||
|
? anyItem.lvl
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const weight: number | undefined =
|
||||||
|
typeof item.weight === "number"
|
||||||
|
? item.weight
|
||||||
|
: typeof anyItem.peso === "number"
|
||||||
|
? anyItem.peso
|
||||||
|
: typeof anyItem.peso_em_oz === "number"
|
||||||
|
? anyItem.peso_em_oz
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const slots: number | undefined =
|
||||||
|
typeof item.slots === "number"
|
||||||
|
? item.slots
|
||||||
|
: typeof anyItem.slots === "number"
|
||||||
|
? anyItem.slots
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const attack: number | undefined =
|
||||||
|
typeof anyItem.ataque === "number"
|
||||||
|
? anyItem.ataque
|
||||||
|
: typeof anyItem.atq === "number"
|
||||||
|
? anyItem.atq
|
||||||
|
: undefined;
|
||||||
|
const defense: number | undefined =
|
||||||
|
typeof anyItem.defesa === "number"
|
||||||
|
? anyItem.defesa
|
||||||
|
: typeof anyItem.def === "number"
|
||||||
|
? anyItem.def
|
||||||
|
: undefined;
|
||||||
|
const armor: number | undefined =
|
||||||
|
typeof anyItem.arm === "number" ? anyItem.arm : undefined;
|
||||||
|
|
||||||
|
// Skill/requisito para usar
|
||||||
|
const skill: string | undefined =
|
||||||
|
typeof item.skill === "string" && item.skill !== "Nenhum."
|
||||||
|
? item.skill
|
||||||
|
: typeof anyItem.skill_para_usar === "string" &&
|
||||||
|
anyItem.skill_para_usar !== "Nenhum."
|
||||||
|
? anyItem.skill_para_usar
|
||||||
|
: undefined;
|
||||||
|
const vocation: string | undefined =
|
||||||
|
typeof anyItem.voc === "string" && anyItem.voc
|
||||||
|
? anyItem.voc
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const sourceTag = [anyItem.source_table_name, anyItem.source_table_id]
|
||||||
|
.filter((v) => v !== undefined && v !== null && String(v).length > 0)
|
||||||
|
.join(":");
|
||||||
|
|
||||||
|
const fallbackKey = `${page}-${idx}`;
|
||||||
|
const safeKey = [category, idSafe ?? sourceTag ?? name ?? fallbackKey, name]
|
||||||
|
.map((v) => String(v || ""))
|
||||||
|
.join("::");
|
||||||
|
|
||||||
|
// Log de integridade de dados
|
||||||
|
if (!idSafe && (!name || name === "Sem nome")) {
|
||||||
|
console.error("[Library] Item inválido sem id/nome", {
|
||||||
|
index: idx,
|
||||||
|
page,
|
||||||
|
category: rawCategory,
|
||||||
|
itemSnapshot: anyItem,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemCard
|
||||||
|
key={safeKey}
|
||||||
|
item={
|
||||||
|
{
|
||||||
|
id: (idSafe ?? sourceTag ?? name ?? fallbackKey) as string,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
category: category as any,
|
||||||
|
rarity: (item.rarity as any) ?? "common",
|
||||||
|
categoryPt,
|
||||||
|
stats: {
|
||||||
|
weight,
|
||||||
|
slots,
|
||||||
|
attack,
|
||||||
|
defense,
|
||||||
|
armor,
|
||||||
|
} as any,
|
||||||
|
requirements: {
|
||||||
|
level,
|
||||||
|
skill,
|
||||||
|
vocation,
|
||||||
|
} as any,
|
||||||
|
// Campos específicos de aljavas (e reutilizáveis para outras categorias)
|
||||||
|
volume:
|
||||||
|
typeof anyItem.volume === "number"
|
||||||
|
? anyItem.volume
|
||||||
|
: undefined,
|
||||||
|
bonus_de_skill:
|
||||||
|
typeof anyItem.bonus_de_skill === "number"
|
||||||
|
? anyItem.bonus_de_skill
|
||||||
|
: undefined,
|
||||||
|
protecao:
|
||||||
|
typeof anyItem.protecao === "string"
|
||||||
|
? anyItem.protecao
|
||||||
|
: undefined,
|
||||||
|
dropa_de:
|
||||||
|
typeof anyItem.dropa_de === "string"
|
||||||
|
? anyItem.dropa_de
|
||||||
|
: undefined,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
mode="catalog"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Página {page} de {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
Próxima
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import LibraryClient from "./LibraryClient";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
image?: string;
|
||||||
|
rarity?: string;
|
||||||
|
npcPrice?: number;
|
||||||
|
price?: { buy: number; sell: number };
|
||||||
|
priceHistory?: { highest: number; lowest: number; average: number };
|
||||||
|
stats?: any;
|
||||||
|
requirements?: any;
|
||||||
|
protections?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchItems(params: URLSearchParams) {
|
||||||
|
const qs = params.toString();
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`,
|
||||||
|
{
|
||||||
|
// Em ambiente server, revalidação curta para manter leve
|
||||||
|
next: { revalidate: 15 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
return { items: [], total: 0, page: 1, pageSize: 24, totalPages: 1 };
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "underConstruction" });
|
||||||
|
return {
|
||||||
|
title: t("market.title"),
|
||||||
|
description: t("market.description"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LibraryPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const sp = (await searchParams) ?? {};
|
||||||
|
const initial = new URLSearchParams();
|
||||||
|
if (typeof sp.q === "string") initial.set("q", sp.q);
|
||||||
|
if (typeof sp.sort === "string") initial.set("sort", sp.sort);
|
||||||
|
if (typeof sp.page === "string") initial.set("page", sp.page);
|
||||||
|
if (typeof sp.pageSize === "string") initial.set("pageSize", sp.pageSize);
|
||||||
|
if (typeof sp.category === "string") initial.set("category", sp.category);
|
||||||
|
if (typeof sp.rarity === "string") initial.set("rarity", sp.rarity);
|
||||||
|
|
||||||
|
const data = await fetchItems(initial);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<main className="container mx-auto px-4 py-8 space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Biblioteca de Itens</h1>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{data.total} {data.total === 1 ? "item" : "itens"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LibraryClient initial={data} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Grid, List, Search, SortAsc, SortDesc, X } from "lucide-react";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AdCard } from "@/components/marketplace/AdCard";
|
||||||
|
import { CreateAdModal } from "@/components/marketplace/CreateAdModal";
|
||||||
|
import { MyAdsPanel } from "@/components/marketplace/MyAdsPanel";
|
||||||
|
import { MyPurchaseIntentionsPanel } from "@/components/marketplace/MyPurchaseIntentionsPanel";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useInvalidateListings, useListings } from "@/lib/hooks/use-listings";
|
||||||
|
import { useMyPurchaseIntentions } from "@/lib/hooks/use-purchase-intentions";
|
||||||
|
import { useSession } from "@/lib/hooks/use-session";
|
||||||
|
import { useMarketTelemetry } from "@/lib/hooks/use-telemetry";
|
||||||
|
|
||||||
|
interface Listing {
|
||||||
|
listing_id: number;
|
||||||
|
seller_id: number;
|
||||||
|
item_id: number;
|
||||||
|
item_name: string;
|
||||||
|
quantity: number;
|
||||||
|
server_name: string;
|
||||||
|
price_pix: number | null;
|
||||||
|
price_tibia_coins: number | null;
|
||||||
|
price_gold_coins: number | null;
|
||||||
|
is_auction: boolean;
|
||||||
|
description: string | null;
|
||||||
|
listing_status: string;
|
||||||
|
created_at: string;
|
||||||
|
views_count: number;
|
||||||
|
favorites_count: number;
|
||||||
|
is_featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InitialData {
|
||||||
|
listings: Listing[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarketClient({ initial }: { initial: InitialData }) {
|
||||||
|
const { user } = useSession();
|
||||||
|
const { trackSearch, trackFilter, trackPageView } = useMarketTelemetry();
|
||||||
|
const invalidateListings = useInvalidateListings();
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const [page, setPage] = useState<number>(initial.page || 1);
|
||||||
|
const [pageSize] = useState<number>(initial.pageSize || 20);
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
|
|
||||||
|
// Filtros
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
const [serverName, setServerName] = useState<string>("");
|
||||||
|
const [sortBy, setSortBy] = useState<string>("created_at");
|
||||||
|
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
|
||||||
|
const [priceMin, setPriceMin] = useState<string>("");
|
||||||
|
const [priceMax, setPriceMax] = useState<string>("");
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("explore");
|
||||||
|
|
||||||
|
// Usar React Query para buscar listings com atualização automática
|
||||||
|
const {
|
||||||
|
listings,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
isLoading: loading,
|
||||||
|
} = useListings({
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
server_name: serverName || undefined,
|
||||||
|
price_min: priceMin || undefined,
|
||||||
|
price_max: priceMax || undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tracking de página
|
||||||
|
useEffect(() => {
|
||||||
|
trackPageView("marketplace");
|
||||||
|
}, [trackPageView]);
|
||||||
|
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
setPage(1);
|
||||||
|
setSearchQuery(query);
|
||||||
|
if (query) {
|
||||||
|
trackSearch(query, { server: serverName });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (newSortBy: string) => {
|
||||||
|
setPage(1);
|
||||||
|
if (sortBy === newSortBy) {
|
||||||
|
// Alternar ordem se já está ordenando por esse campo
|
||||||
|
setSortOrder(sortOrder === "ASC" ? "DESC" : "ASC");
|
||||||
|
} else {
|
||||||
|
setSortBy(newSortBy);
|
||||||
|
setSortOrder("DESC");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilter = (filters: Record<string, any>) => {
|
||||||
|
setPage(1);
|
||||||
|
if (filters.server) setServerName(filters.server);
|
||||||
|
if (filters.priceMin !== undefined) setPriceMin(String(filters.priceMin));
|
||||||
|
if (filters.priceMax !== undefined) setPriceMax(String(filters.priceMax));
|
||||||
|
trackFilter(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setServerName("");
|
||||||
|
setPriceMin("");
|
||||||
|
setPriceMax("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: "created_at", label: "Mais recentes", icon: SortDesc },
|
||||||
|
{ value: "price_pix", label: "Menor preço (PIX)", icon: SortAsc },
|
||||||
|
{ value: "views_count", label: "Mais populares", icon: SortDesc },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeFiltersCount = [
|
||||||
|
searchQuery,
|
||||||
|
serverName,
|
||||||
|
priceMin,
|
||||||
|
priceMax,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header com botão de criar anúncio */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Marketplace</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{activeTab === "explore"
|
||||||
|
? `${total} ${total === 1 ? "anúncio" : "anúncios"} disponíveis`
|
||||||
|
: "Gerencie seus anúncios"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{user && (
|
||||||
|
<Button onClick={() => setShowCreateModal(true)}>
|
||||||
|
Criar Anúncio
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs para Explorar, Meus Anúncios e Minhas Intenções */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="explore">Explorar</TabsTrigger>
|
||||||
|
{user && <TabsTrigger value="my-ads">Meus Anúncios</TabsTrigger>}
|
||||||
|
{user && <MyPurchaseIntentionsTabTrigger />}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Aba: Explorar Anúncios */}
|
||||||
|
<TabsContent value="explore" className="space-y-6 mt-6">
|
||||||
|
{/* Barra de busca */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar anúncios por item ou descrição..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="pl-10 pr-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtros e controles */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{/* Filtro por servidor */}
|
||||||
|
<Input
|
||||||
|
placeholder="Filtrar por servidor"
|
||||||
|
value={serverName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setServerName(e.target.value);
|
||||||
|
handleFilter({ server: e.target.value });
|
||||||
|
}}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filtro por preço mínimo */}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Preço min"
|
||||||
|
value={priceMin}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPriceMin(e.target.value);
|
||||||
|
handleFilter({
|
||||||
|
priceMin: e.target.value
|
||||||
|
? parseFloat(e.target.value)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filtro por preço máximo */}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Preço max"
|
||||||
|
value={priceMax}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPriceMax(e.target.value);
|
||||||
|
handleFilter({
|
||||||
|
priceMax: e.target.value
|
||||||
|
? parseFloat(e.target.value)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ordenação */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
{(() => {
|
||||||
|
const selectedOption = sortOptions.find(
|
||||||
|
(opt) => opt.value === sortBy,
|
||||||
|
);
|
||||||
|
const Icon = selectedOption?.icon;
|
||||||
|
return Icon ? <Icon className="h-4 w-4" /> : null;
|
||||||
|
})()}
|
||||||
|
{sortOptions.find((opt) => opt.value === sortBy)?.label ||
|
||||||
|
"Ordenar"}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-48">
|
||||||
|
<DropdownMenuLabel>Ordenar por</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{sortOptions.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleSort(option.value)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{sortBy === option.value && (
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full ml-auto" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Toggle de visualização */}
|
||||||
|
<div className="flex items-center gap-1 border rounded-md p-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
>
|
||||||
|
<Grid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "list" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Limpar filtros */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<Button variant="ghost" onClick={clearFilters} className="gap-2">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Limpar Filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges de filtros ativos */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{searchQuery && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
Busca: {searchQuery}
|
||||||
|
<X
|
||||||
|
className="h-3 w-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{serverName && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
Servidor: {serverName}
|
||||||
|
<X
|
||||||
|
className="h-3 w-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setServerName("");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{(priceMin || priceMax) && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
Preço: {priceMin || "0"} - {priceMax || "∞"}
|
||||||
|
<X
|
||||||
|
className="h-3 w-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setPriceMin("");
|
||||||
|
setPriceMax("");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Listagem de anúncios */}
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
viewMode === "grid"
|
||||||
|
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||||
|
: "space-y-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-64 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : listings.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Nenhum anúncio encontrado
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm mt-2">
|
||||||
|
Tente ajustar os filtros ou criar um novo anúncio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
viewMode === "grid"
|
||||||
|
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||||
|
: "space-y-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{listings.map((listing) => (
|
||||||
|
<AdCard
|
||||||
|
key={listing.listing_id}
|
||||||
|
listing={listing}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paginação */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Página {page} de {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
Próxima
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de criação de anúncio */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<CreateAdModal
|
||||||
|
open={showCreateModal}
|
||||||
|
onOpenChange={setShowCreateModal}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
// Invalidar cache para recarregar lista automaticamente
|
||||||
|
invalidateListings();
|
||||||
|
// Se estiver na aba explore, mudar para meus anúncios para ver o novo
|
||||||
|
if (activeTab === "explore") {
|
||||||
|
setActiveTab("my-ads");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Aba: Meus Anúncios */}
|
||||||
|
{user && (
|
||||||
|
<TabsContent value="my-ads" className="mt-6">
|
||||||
|
<MyAdsPanel />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aba: Minhas Intenções */}
|
||||||
|
{user && (
|
||||||
|
<TabsContent value="my-intentions" className="mt-6">
|
||||||
|
<MyPurchaseIntentionsPanel locale={locale} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente para o trigger da tab com badge de contador
|
||||||
|
function MyPurchaseIntentionsTabTrigger() {
|
||||||
|
const { intentions } = useMyPurchaseIntentions();
|
||||||
|
const pendingCount = intentions.filter((i) => i.status === "pending").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsTrigger value="my-intentions" className="relative">
|
||||||
|
Minhas Intenções
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="ml-2 h-5 min-w-5 px-1.5 text-xs"
|
||||||
|
>
|
||||||
|
{pendingCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import { AnalyticsDashboard } from "@/components/marketplace/AnalyticsDashboard";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: "Analytics - Marketplace",
|
||||||
|
description: "Acompanhe o desempenho dos seus anúncios",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AnalyticsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
// Verificar autenticação (server-side)
|
||||||
|
// Nota: Em produção, usar headers() do Next.js para obter request
|
||||||
|
// Por enquanto, deixar o componente fazer a verificação client-side
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<AnalyticsDashboard />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,110 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import { UnderConstruction } from "@/components/under-construction";
|
import { getActiveListings } from "@/lib/prisma/queries/listings";
|
||||||
import { getTranslations } from "next-intl/server";
|
import MarketClient from "./MarketClient";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: "underConstruction" });
|
|
||||||
|
const titles = {
|
||||||
|
pt: "Mercado - Todos os Itens de Tibia",
|
||||||
|
en: "Market - All Tibia Items",
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptions = {
|
||||||
|
pt: "Explore todos os itens disponíveis no marketplace: armaduras, armas, escudos, poções e mais. Compre e venda com segurança usando nosso sistema de escrow.",
|
||||||
|
en: "Explore all items available in the marketplace: armors, weapons, shields, potions and more. Buy and sell safely using our escrow system.",
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: t("market.title"),
|
title: titles[locale as keyof typeof titles],
|
||||||
description: t("market.description"),
|
description: descriptions[locale as keyof typeof descriptions],
|
||||||
|
keywords: [
|
||||||
|
"itens tibia",
|
||||||
|
"armaduras tibia",
|
||||||
|
"armas tibia",
|
||||||
|
"escudos tibia",
|
||||||
|
"comprar itens tibia",
|
||||||
|
"marketplace tibia",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MarketPage({
|
export default async function MarketPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({
|
const sp = (await searchParams) ?? {};
|
||||||
locale,
|
|
||||||
namespace: "underConstruction.market",
|
const page = typeof sp.page === "string" ? parseInt(sp.page, 10) : 1;
|
||||||
|
const pageSize =
|
||||||
|
typeof sp.pageSize === "string" ? parseInt(sp.pageSize, 10) : 20;
|
||||||
|
|
||||||
|
// Buscar anúncios ativos
|
||||||
|
const listingsData = await getActiveListings({
|
||||||
|
search: typeof sp.search === "string" ? sp.search : undefined,
|
||||||
|
server_name:
|
||||||
|
typeof sp.server_name === "string" ? sp.server_name : undefined,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortBy: (typeof sp.sortBy === "string" ? sp.sortBy : "created_at") as
|
||||||
|
| "created_at"
|
||||||
|
| "price_pix"
|
||||||
|
| "price_tibia_coins"
|
||||||
|
| "price_gold_coins"
|
||||||
|
| "views_count",
|
||||||
|
sortOrder: (typeof sp.sortOrder === "string" ? sp.sortOrder : "DESC") as
|
||||||
|
| "ASC"
|
||||||
|
| "DESC",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Converter Date para string para compatibilidade com o tipo do cliente
|
||||||
|
const serializedListings = listingsData.listings.map((listing) => ({
|
||||||
|
...listing,
|
||||||
|
created_at:
|
||||||
|
listing.created_at instanceof Date
|
||||||
|
? listing.created_at.toISOString()
|
||||||
|
: listing.created_at,
|
||||||
|
updated_at:
|
||||||
|
listing.updated_at instanceof Date
|
||||||
|
? listing.updated_at.toISOString()
|
||||||
|
: listing.updated_at,
|
||||||
|
sold_at:
|
||||||
|
listing.sold_at instanceof Date
|
||||||
|
? listing.sold_at.toISOString()
|
||||||
|
: listing.sold_at,
|
||||||
|
auction_ends_at:
|
||||||
|
listing.auction_ends_at instanceof Date
|
||||||
|
? listing.auction_ends_at.toISOString()
|
||||||
|
: listing.auction_ends_at,
|
||||||
|
expires_at:
|
||||||
|
listing.expires_at instanceof Date
|
||||||
|
? listing.expires_at.toISOString()
|
||||||
|
: listing.expires_at,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<UnderConstruction
|
<main className="container mx-auto px-4 py-8">
|
||||||
title={t("title")}
|
<MarketClient
|
||||||
description={t("description")}
|
initial={{
|
||||||
locale={locale}
|
listings: serializedListings as any,
|
||||||
/>
|
total: listingsData.total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(listingsData.total / pageSize),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
|
import { DonationFooterCard } from "@/components/donation-footer-card";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import { EscrowBanner } from "@/components/sections/escrow-banner";
|
import { EscrowBanner } from "@/components/sections/escrow-banner";
|
||||||
import { EscrowExplanation } from "@/components/sections/escrow-explanation";
|
import { EscrowExplanation } from "@/components/sections/escrow-explanation";
|
||||||
|
import { FeaturedItems } from "@/components/sections/featured-items";
|
||||||
import { HeroSection } from "@/components/sections/hero-section";
|
import { HeroSection } from "@/components/sections/hero-section";
|
||||||
import { HowItWorks } from "@/components/sections/how-it-works";
|
import { HowItWorks } from "@/components/sections/how-it-works";
|
||||||
import { FeaturedItems } from "@/components/sections/featured-items";
|
import { MarketExample } from "@/components/sections/market-example";
|
||||||
import { MarketOffers } from "@/components/sections/market-offers";
|
|
||||||
import { MarketStats } from "@/components/sections/market-stats";
|
import { MarketStats } from "@/components/sections/market-stats";
|
||||||
import { DonationFooterCard } from "@/components/donation-footer-card";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<EscrowBanner />
|
<EscrowBanner />
|
||||||
<main>
|
<main aria-label="Página principal do Tibia Trade">
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
<HowItWorks />
|
<HowItWorks />
|
||||||
<FeaturedItems />
|
<FeaturedItems />
|
||||||
<MarketOffers />
|
<MarketExample />
|
||||||
<MarketStats />
|
<MarketStats />
|
||||||
</main>
|
</main>
|
||||||
<EscrowExplanation />
|
<EscrowExplanation />
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import { UnderConstruction } from "@/components/under-construction";
|
import { UnderConstruction } from "@/components/under-construction";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: "underConstruction" });
|
const _t = await getTranslations({ locale, namespace: "underConstruction" });
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
pt: "Preços - Estrutura de Custos",
|
||||||
|
en: "Prices - Cost Structure",
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptions = {
|
||||||
|
pt: "Veja nossa estrutura de preços transparente. Taxas de transação justas para manter o marketplace funcionando.",
|
||||||
|
en: "See our transparent price structure. Fair transaction fees to keep the marketplace running.",
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: t("prices.title"),
|
title: titles[locale as keyof typeof titles],
|
||||||
description: t("prices.description"),
|
description: descriptions[locale as keyof typeof descriptions],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import { UnderConstruction } from "@/components/under-construction";
|
import { UnderConstruction } from "@/components/under-construction";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: "underConstruction" });
|
|
||||||
|
const titles = {
|
||||||
|
pt: "Sobre - Nossa Missão e História",
|
||||||
|
en: "About - Our Mission and History",
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptions = {
|
||||||
|
pt: "Conheça o Tibia Trade, o marketplace multi-servidor mais seguro para negociação de itens. Sistema escrow, transparência total e comunidade ativa.",
|
||||||
|
en: "Learn about Tibia Trade, the most secure multi-server marketplace for item trading. Escrow system, total transparency and active community.",
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: t("about.title"),
|
title: titles[locale as keyof typeof titles],
|
||||||
description: t("about.description"),
|
description: descriptions[locale as keyof typeof descriptions],
|
||||||
|
keywords: [
|
||||||
|
"sobre tibia trade",
|
||||||
|
"história tibia trade",
|
||||||
|
"missão tibia trade",
|
||||||
|
"marketplace tibia",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
addColumnToCategory,
|
||||||
|
type SQLiteColumnDefinition,
|
||||||
|
} from "@/lib/items/sqlite-manager";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
function sanitizeColumn(column: any): SQLiteColumnDefinition {
|
||||||
|
const name =
|
||||||
|
typeof column?.name === "string" ? column.name.trim().toLowerCase() : "";
|
||||||
|
const type = typeof column?.type === "string" ? column.type : "TEXT";
|
||||||
|
const notNull = column?.notNull === true;
|
||||||
|
const defaultValue = column?.defaultValue ?? undefined;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Nome da coluna é obrigatório");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["id", "nome", "image_url", "image_local_path"].includes(name)) {
|
||||||
|
throw new Error("Coluna reservada não pode ser criada");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: type.toUpperCase(),
|
||||||
|
notNull,
|
||||||
|
defaultValue,
|
||||||
|
editable: column?.editable !== false,
|
||||||
|
} satisfies SQLiteColumnDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ name: string }> },
|
||||||
|
) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const column = sanitizeColumn(body?.column ?? body);
|
||||||
|
const created = addColumnToCategory({ category: name, column });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, column: created },
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("Falha ao adicionar coluna à categoria", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Erro ao adicionar coluna",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
type CreateCategoryInput,
|
||||||
|
createCategoryTemplate,
|
||||||
|
listCategoryTemplates,
|
||||||
|
type SQLiteColumnDefinition,
|
||||||
|
} from "@/lib/items/sqlite-manager";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const templates = listCategoryTemplates();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, categories: templates },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("Falha ao listar categorias dinâmicas", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Não foi possível listar categorias" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeColumn(column: any): SQLiteColumnDefinition {
|
||||||
|
const name =
|
||||||
|
typeof column?.name === "string" ? column.name.trim().toLowerCase() : "";
|
||||||
|
const type = typeof column?.type === "string" ? column.type : "TEXT";
|
||||||
|
const notNull = column?.notNull === true;
|
||||||
|
const defaultValue = column?.defaultValue ?? undefined;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Nome da coluna é obrigatório");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: type.toUpperCase(),
|
||||||
|
notNull,
|
||||||
|
defaultValue,
|
||||||
|
editable: column?.editable !== false,
|
||||||
|
} satisfies SQLiteColumnDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const rawName =
|
||||||
|
typeof body?.name === "string" ? body.name.trim().toLowerCase() : "";
|
||||||
|
if (!rawName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Nome da categoria é obrigatório" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnsArray: unknown[] = Array.isArray(body?.columns)
|
||||||
|
? body.columns
|
||||||
|
: [];
|
||||||
|
const columns = columnsArray
|
||||||
|
.map((column) => sanitizeColumn(column))
|
||||||
|
.filter((column) => {
|
||||||
|
return !(
|
||||||
|
column &&
|
||||||
|
["id", "nome", "image_url", "image_local_path"].includes(column.name)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input: CreateCategoryInput = {
|
||||||
|
name: rawName,
|
||||||
|
columns,
|
||||||
|
};
|
||||||
|
|
||||||
|
const category = createCategoryTemplate(input);
|
||||||
|
return NextResponse.json({ success: true, category }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("Falha ao criar categoria dinâmica", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Erro ao criar categoria",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { generateItemStructure } from "@/lib/ai/generate-item-structure";
|
||||||
|
import { calculateTokenCost } from "@/lib/ai/token-pricing";
|
||||||
|
import { recordTokenUsage } from "@/lib/ai/token-usage";
|
||||||
|
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||||
|
const description =
|
||||||
|
typeof body?.description === "string" ? body.description.trim() : "";
|
||||||
|
const scope = body?.scope === "server" ? "server" : "global";
|
||||||
|
const server =
|
||||||
|
scope === "server" && typeof body?.server === "string"
|
||||||
|
? body.server.trim()
|
||||||
|
: null;
|
||||||
|
const categories: string[] = Array.isArray(body?.categories)
|
||||||
|
? body.categories.filter((item: unknown) => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
const servers: string[] = Array.isArray(body?.servers)
|
||||||
|
? body.servers.filter((item: unknown) => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
const images: string[] = Array.isArray(body?.images)
|
||||||
|
? body.images.filter((item: unknown) => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
const hasDescription = !!description;
|
||||||
|
const hasImages = images.length > 0;
|
||||||
|
|
||||||
|
// Nova regra: aceitar quando houver descrição OU ao menos uma imagem.
|
||||||
|
// Nome pode ser vazio; o assistente do front lida com sugestão/confirmar_nome.
|
||||||
|
if (!hasDescription && !hasImages) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Forneça descrição ou ao menos uma imagem para análise.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateItemStructure({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
scope,
|
||||||
|
server,
|
||||||
|
categories,
|
||||||
|
servers,
|
||||||
|
images: images
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((dataUrl) => {
|
||||||
|
const [meta, base64Part] = dataUrl.split(",");
|
||||||
|
const mimeMatch = meta?.match(/data:(.*);base64/);
|
||||||
|
return {
|
||||||
|
mimeType: mimeMatch?.[1] ?? "image/jpeg",
|
||||||
|
base64Data: base64Part ?? meta,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((image) => image.base64Data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cálculo de custo baseado em usageMetadata
|
||||||
|
const _model = (result.model ??
|
||||||
|
"gemini-2.5-flash") as keyof typeof import("@/lib/ai/token-pricing").TOKEN_PRICING;
|
||||||
|
const breakdown = calculateTokenCost("gemini-2.5-flash", result.usage);
|
||||||
|
|
||||||
|
// Persistir histórico para relatórios (não bloqueia resposta; erros são logados no Redis util)
|
||||||
|
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
try {
|
||||||
|
await recordTokenUsage({
|
||||||
|
id: requestId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
model: result.model ?? "gemini-2.5-flash",
|
||||||
|
inputTokens: breakdown.inputTokens,
|
||||||
|
outputTokens: breakdown.outputTokens,
|
||||||
|
totalTokens: breakdown.totalTokens,
|
||||||
|
costUsd: breakdown.totalUsd,
|
||||||
|
context: {
|
||||||
|
route: "/api/admin/items/analyze",
|
||||||
|
scope,
|
||||||
|
server,
|
||||||
|
hasImages,
|
||||||
|
hasDescription,
|
||||||
|
categoriesCount: categories.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Falhas de Redis não devem impedir a análise
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
structure: result.structure,
|
||||||
|
usage: {
|
||||||
|
promptTokens: breakdown.inputTokens,
|
||||||
|
outputTokens: breakdown.outputTokens,
|
||||||
|
totalTokens: breakdown.totalTokens,
|
||||||
|
},
|
||||||
|
costUsd: breakdown.totalUsd,
|
||||||
|
model: result.model ?? "gemini-2.5-flash",
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
insertCategoryItem,
|
||||||
|
listCategoryTemplates,
|
||||||
|
updateCategoryItem,
|
||||||
|
} from "@/lib/items/sqlite-manager";
|
||||||
|
import {
|
||||||
|
createAdminItem,
|
||||||
|
getItemById,
|
||||||
|
searchAdminItems,
|
||||||
|
updateAdminItem,
|
||||||
|
} from "@/lib/prisma/queries/items";
|
||||||
|
|
||||||
|
function parseNumber(
|
||||||
|
value: string | null | undefined,
|
||||||
|
fallback: number,
|
||||||
|
): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDynamicFields(
|
||||||
|
category: string,
|
||||||
|
rawFields: Record<string, unknown> | undefined,
|
||||||
|
) {
|
||||||
|
if (!rawFields || typeof rawFields !== "object") {
|
||||||
|
return {} as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = listCategoryTemplates();
|
||||||
|
const template = templates.find(
|
||||||
|
(entry) => entry.name === category.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!template) {
|
||||||
|
return rawFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnMap = new Map(
|
||||||
|
template.columns.map((column) => [column.name, column]),
|
||||||
|
);
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(rawFields)) {
|
||||||
|
if (["id", "nome", "image_url", "image_local_path"].includes(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = columnMap.get(key);
|
||||||
|
if (!column) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "" || value === null || value === undefined) {
|
||||||
|
normalized[key] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (column.type) {
|
||||||
|
case "INTEGER":
|
||||||
|
case "BOOLEAN": {
|
||||||
|
const parsed = Number(value);
|
||||||
|
normalized[key] = Number.isFinite(parsed) ? parsed : null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "REAL": {
|
||||||
|
const parsed = Number(value);
|
||||||
|
normalized[key] = Number.isFinite(parsed) ? parsed : null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
normalized[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseNumber(searchParams.get("page"), 1);
|
||||||
|
const pageSize = Math.min(parseNumber(searchParams.get("pageSize"), 50), 200);
|
||||||
|
const q = searchParams.get("q") || undefined;
|
||||||
|
const category = searchParams.get("category") || undefined;
|
||||||
|
const rarity = searchParams.get("rarity") || undefined;
|
||||||
|
const server = searchParams.get("server") || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await searchAdminItems({
|
||||||
|
q,
|
||||||
|
category,
|
||||||
|
rarity,
|
||||||
|
server,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result, { status: 200 });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[admin/items][GET] erro", error?.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Falha ao consultar itens",
|
||||||
|
message: error?.message || "Erro desconhecido",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||||
|
const category =
|
||||||
|
typeof body?.category === "string"
|
||||||
|
? body.category.trim().toLowerCase()
|
||||||
|
: "";
|
||||||
|
const originServer =
|
||||||
|
typeof body?.server === "string" ? body.server.trim() || null : null;
|
||||||
|
|
||||||
|
if (!name || !category) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Nome e categoria são obrigatórios." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicFields = normalizeDynamicFields(category, body?.fields);
|
||||||
|
|
||||||
|
const imageUrl =
|
||||||
|
typeof body?.imageUrl === "string" ? body.imageUrl.trim() || null : null;
|
||||||
|
const imageLocalPath =
|
||||||
|
typeof body?.imageLocalPath === "string"
|
||||||
|
? body.imageLocalPath.trim() || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sqliteData = {
|
||||||
|
nome: name,
|
||||||
|
image_url: imageUrl,
|
||||||
|
image_local_path: imageLocalPath,
|
||||||
|
...dynamicFields,
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Se enviado servidor de origem, inclui na linha do SQLite e garante presença na lista de servidores do item
|
||||||
|
if (originServer) {
|
||||||
|
sqliteData.server = originServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqliteId = insertCategoryItem({ category, data: sqliteData });
|
||||||
|
|
||||||
|
const metadataBase =
|
||||||
|
typeof body?.metadata === "object" && body.metadata !== null
|
||||||
|
? body.metadata
|
||||||
|
: {};
|
||||||
|
const metadata = { ...metadataBase, ...dynamicFields } as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
const itemServers = parseList(body?.servers);
|
||||||
|
const finalServers = originServer
|
||||||
|
? Array.from(new Set([originServer, ...itemServers]))
|
||||||
|
: itemServers;
|
||||||
|
|
||||||
|
const item = await createAdminItem({
|
||||||
|
item_name: name,
|
||||||
|
category,
|
||||||
|
rarity:
|
||||||
|
typeof body?.rarity === "string" ? body.rarity.trim() || null : null,
|
||||||
|
image_url: imageUrl,
|
||||||
|
image_local_path: imageLocalPath,
|
||||||
|
level: typeof body?.level === "number" ? body.level : null,
|
||||||
|
weight: typeof body?.weight === "number" ? body.weight : null,
|
||||||
|
slots: typeof body?.slots === "number" ? body.slots : null,
|
||||||
|
skill: typeof body?.skill === "string" ? body.skill.trim() || null : null,
|
||||||
|
servers: finalServers,
|
||||||
|
tags: parseList(body?.tags),
|
||||||
|
description:
|
||||||
|
typeof body?.description === "string"
|
||||||
|
? body.description.trim() || null
|
||||||
|
: null,
|
||||||
|
metadata,
|
||||||
|
source_table_name: category,
|
||||||
|
source_table_id: sqliteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
item,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[admin/items][POST] erro", error?.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Falha ao criar item",
|
||||||
|
message: error?.message || "Erro desconhecido",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const id = Number(body?.id);
|
||||||
|
|
||||||
|
if (!Number.isFinite(id) || id <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID inválido." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getItemById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Item não encontrado." },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const category =
|
||||||
|
(typeof body?.category === "string"
|
||||||
|
? body.category.trim().toLowerCase()
|
||||||
|
: existing.category) ?? existing.category;
|
||||||
|
if (category !== existing.category) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Alterar a categoria de um item não é suportado.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicFields = normalizeDynamicFields(category, body?.fields);
|
||||||
|
|
||||||
|
const imageUrl =
|
||||||
|
typeof body?.imageUrl === "string" ? body.imageUrl.trim() : undefined;
|
||||||
|
const imageLocalPath =
|
||||||
|
typeof body?.imageLocalPath === "string"
|
||||||
|
? body.imageLocalPath.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (existing.source_table_id && existing.source_table_name) {
|
||||||
|
const sqliteData: Record<string, unknown> = {
|
||||||
|
nome:
|
||||||
|
typeof body?.name === "string"
|
||||||
|
? body.name.trim()
|
||||||
|
: existing.item_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imageUrl !== undefined) sqliteData.image_url = imageUrl;
|
||||||
|
else sqliteData.image_url = existing.image_url;
|
||||||
|
|
||||||
|
if (imageLocalPath !== undefined)
|
||||||
|
sqliteData.image_local_path = imageLocalPath;
|
||||||
|
else sqliteData.image_local_path = existing.image_local_path;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(dynamicFields)) {
|
||||||
|
sqliteData[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCategoryItem(
|
||||||
|
existing.source_table_name,
|
||||||
|
existing.source_table_id,
|
||||||
|
sqliteData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataBase =
|
||||||
|
typeof body?.metadata === "object" && body.metadata !== null
|
||||||
|
? body.metadata
|
||||||
|
: existing.metadata;
|
||||||
|
const metadata = { ...metadataBase, ...dynamicFields } as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
const item = await updateAdminItem(id, {
|
||||||
|
item_name: typeof body?.name === "string" ? body.name.trim() : undefined,
|
||||||
|
category,
|
||||||
|
rarity: typeof body?.rarity === "string" ? body.rarity.trim() : undefined,
|
||||||
|
image_url: imageUrl,
|
||||||
|
image_local_path: imageLocalPath,
|
||||||
|
level: typeof body?.level === "number" ? body.level : undefined,
|
||||||
|
weight: typeof body?.weight === "number" ? body.weight : undefined,
|
||||||
|
slots: typeof body?.slots === "number" ? body.slots : undefined,
|
||||||
|
skill: typeof body?.skill === "string" ? body.skill.trim() : undefined,
|
||||||
|
servers: parseList(body?.servers),
|
||||||
|
tags: parseList(body?.tags),
|
||||||
|
description:
|
||||||
|
typeof body?.description === "string"
|
||||||
|
? body.description.trim()
|
||||||
|
: body?.description === null
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
item,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[admin/items][PUT] erro", error?.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Falha ao atualizar item",
|
||||||
|
message: error?.message || "Erro desconhecido",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { generateAccessToken, generateRefreshToken } from "@/lib/auth/jwt";
|
||||||
|
import { comparePassword } from "@/lib/auth/password";
|
||||||
|
import {
|
||||||
|
getDefaultSubscriptionTier,
|
||||||
|
getSubscriptionTierById,
|
||||||
|
} from "@/lib/prisma/queries/subscription-tiers";
|
||||||
|
import {
|
||||||
|
getUserByEmail,
|
||||||
|
getUserByUsername,
|
||||||
|
updateLastLogin,
|
||||||
|
} from "@/lib/prisma/queries/users";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
createRateLimitResponse,
|
||||||
|
getClientIP,
|
||||||
|
} from "@/lib/rate-limiter";
|
||||||
|
import { setSession } from "@/lib/redis/session";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { loginSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const rateLimit = checkRateLimit(ip, "/api/auth/login");
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return createRateLimitResponse(rateLimit.resetAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
secureLogger.info("📥 POST /api/auth/login - Recebido", {
|
||||||
|
ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validação com Zod
|
||||||
|
const validationResult = loginSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
const errors = validationResult.error.issues.map((err) => ({
|
||||||
|
field: err.path.join("."),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Dados de entrada inválidos",
|
||||||
|
details: errors,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { emailOrUsername, password } = validationResult.data;
|
||||||
|
|
||||||
|
// Buscar usuário por email ou username
|
||||||
|
const user =
|
||||||
|
(await getUserByEmail(emailOrUsername)) ||
|
||||||
|
(await getUserByUsername(emailOrUsername));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
secureLogger.warn("⚠️ Tentativa de login com credenciais inválidas", {
|
||||||
|
emailOrUsername: secureLogger.mask({ emailOrUsername }),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Email/username ou senha incorretos",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se usuário está ativo
|
||||||
|
if (!user.is_active) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Conta desativada. Entre em contato com o suporte.",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar senha
|
||||||
|
if (!user.password_hash) {
|
||||||
|
secureLogger.error("❌ Usuário sem password_hash", {
|
||||||
|
userId: user.user_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro interno. Tente novamente mais tarde.",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMatch = await comparePassword(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!passwordMatch) {
|
||||||
|
secureLogger.warn("⚠️ Senha incorreta", {
|
||||||
|
userId: user.user_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Email/username ou senha incorretos",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar último login
|
||||||
|
await updateLastLogin(user.user_id);
|
||||||
|
|
||||||
|
// Gerar tokens
|
||||||
|
const tierInfo =
|
||||||
|
user.subscription_tier ??
|
||||||
|
(user.subscription_tier_id
|
||||||
|
? await getSubscriptionTierById(user.subscription_tier_id)
|
||||||
|
: await getDefaultSubscriptionTier());
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(user.user_id, user.username, {
|
||||||
|
tierSlug: tierInfo?.slug ?? undefined,
|
||||||
|
tierLevel: tierInfo?.level ?? undefined,
|
||||||
|
});
|
||||||
|
const refreshToken = generateRefreshToken(user.user_id, user.username, {
|
||||||
|
tierSlug: tierInfo?.slug ?? undefined,
|
||||||
|
tierLevel: tierInfo?.level ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar sessão no Redis
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
await setSession(sessionId, {
|
||||||
|
userId: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
accessToken,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
subscriptionTierId: tierInfo?.subscription_tier_id ?? null,
|
||||||
|
subscriptionTierSlug: tierInfo?.slug ?? null,
|
||||||
|
subscriptionTierName: tierInfo?.name ?? null,
|
||||||
|
subscriptionTierLevel: tierInfo?.level ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar resposta
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
userId: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
accountLevel: user.account_level,
|
||||||
|
hasDonated: user.has_donated,
|
||||||
|
subscriptionTier: tierInfo
|
||||||
|
? {
|
||||||
|
id: tierInfo.subscription_tier_id,
|
||||||
|
name: tierInfo.name,
|
||||||
|
slug: tierInfo.slug,
|
||||||
|
level: tierInfo.level,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
accessToken,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adicionar refresh token e sessionId como cookies httpOnly
|
||||||
|
response.cookies.set("refreshToken", refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 dias
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set("sessionId", sessionId, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 dias
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Login realizado com sucesso", {
|
||||||
|
userId: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao fazer login", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { getUserCharacters } from "@/lib/prisma/queries/characters";
|
||||||
|
import { getUserById } from "@/lib/prisma/queries/users";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Middleware de autenticação
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
// Se retornou NextResponse, é um erro de autenticação
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
// Buscar dados do usuário
|
||||||
|
const user = await getUserById(auth.userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Usuário não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar personagens do usuário
|
||||||
|
const characters = await getUserCharacters(auth.userId);
|
||||||
|
|
||||||
|
secureLogger.info("✅ Perfil do usuário recuperado", {
|
||||||
|
userId: auth.userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
userId: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
phone: user.phone,
|
||||||
|
cpf: user.cpf,
|
||||||
|
accountLevel: user.account_level,
|
||||||
|
hasDonated: user.has_donated,
|
||||||
|
whatsappNotifications: user.whatsapp_notifications,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
updatedAt: user.updated_at,
|
||||||
|
lastLogin: user.last_login,
|
||||||
|
isActive: user.is_active,
|
||||||
|
isVerified: user.is_verified,
|
||||||
|
},
|
||||||
|
characters: characters.map((char) => ({
|
||||||
|
characterId: char.character_id,
|
||||||
|
characterName: char.character_name,
|
||||||
|
serverName: char.server_name,
|
||||||
|
isMain: char.is_main,
|
||||||
|
verified: char.verified,
|
||||||
|
createdAt: char.created_at,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Se for NextResponse (erro de autenticação), re-lançar
|
||||||
|
if (error instanceof NextResponse) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao buscar perfil do usuário", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { generateAccessToken, verifyRefreshToken } from "@/lib/auth/jwt";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
createRateLimitResponse,
|
||||||
|
getClientIP,
|
||||||
|
} 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/auth/refresh");
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return createRateLimitResponse(rateLimit.resetAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrair refresh token do cookie
|
||||||
|
const refreshToken = request.cookies.get("refreshToken")?.value;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Refresh token não fornecido",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar refresh token
|
||||||
|
const payload = verifyRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
// Token inválido - limpar cookie
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Refresh token inválido ou expirado",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
|
||||||
|
response.cookies.delete("refreshToken");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerar novo access token
|
||||||
|
const accessToken = generateAccessToken(payload.userId, payload.username);
|
||||||
|
|
||||||
|
secureLogger.info("✅ Token renovado com sucesso", {
|
||||||
|
userId: payload.userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
accessToken,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao renovar token", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { generateAccessToken, generateRefreshToken } from "@/lib/auth/jwt";
|
||||||
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
|
import { isServerActive } from "@/lib/prisma/queries/servers";
|
||||||
|
import { getDefaultSubscriptionTier } from "@/lib/prisma/queries/subscription-tiers";
|
||||||
|
import { getUserByEmail, getUserByUsername } from "@/lib/prisma/queries/users";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
createRateLimitResponse,
|
||||||
|
getClientIP,
|
||||||
|
} from "@/lib/rate-limiter";
|
||||||
|
import { setSession } from "@/lib/redis/session";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { registerSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const rateLimit = checkRateLimit(ip, "/api/auth/register");
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return createRateLimitResponse(rateLimit.resetAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
secureLogger.info("📥 POST /api/auth/register - Recebido", {
|
||||||
|
ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validação com Zod
|
||||||
|
const validationResult = registerSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
const errors = validationResult.error.issues.map((err) => ({
|
||||||
|
field: err.path.join("."),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
secureLogger.error("❌ Validação falhou", {
|
||||||
|
errors: JSON.stringify(errors),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Dados de entrada inválidos",
|
||||||
|
details: errors,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedData = validationResult.data;
|
||||||
|
|
||||||
|
// Verificar duplicidade: email
|
||||||
|
const existingEmail = await getUserByEmail(validatedData.email);
|
||||||
|
if (existingEmail) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Email já está em uso",
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar duplicidade: username
|
||||||
|
const existingUsername = await getUserByUsername(validatedData.username);
|
||||||
|
if (existingUsername) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Username já está em uso",
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar servidores: verificar se existem e estão ativos
|
||||||
|
const serverNames = validatedData.servers.map((s) => s.serverName);
|
||||||
|
const uniqueServerNames = [...new Set(serverNames)]; // Remover duplicatas
|
||||||
|
|
||||||
|
for (const serverName of uniqueServerNames) {
|
||||||
|
const isActive = await isServerActive(serverName);
|
||||||
|
if (!isActive) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `Servidor "${serverName}" não existe ou não está ativo`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash da senha
|
||||||
|
const passwordHash = await hashPassword(validatedData.password);
|
||||||
|
|
||||||
|
// Criar usuário e personagens em transação atômica usando Prisma
|
||||||
|
const { prisma } = await import("@/lib/prisma/app");
|
||||||
|
|
||||||
|
const createdCharacters: Array<{
|
||||||
|
characterName: string;
|
||||||
|
serverName: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Usar transação Prisma
|
||||||
|
const newUser = await prisma.$transaction(async (tx) => {
|
||||||
|
// Criar usuário
|
||||||
|
const user = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
username: validatedData.username,
|
||||||
|
email: validatedData.email,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
first_name: validatedData.firstName,
|
||||||
|
last_name: validatedData.lastName,
|
||||||
|
phone: validatedData.phone || null,
|
||||||
|
cpf: null, // CPF não é mais obrigatório
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
user_id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
userId: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Criar personagens (primeiro é main por padrão)
|
||||||
|
for (let i = 0; i < validatedData.servers.length; i++) {
|
||||||
|
const serverData = validatedData.servers[i];
|
||||||
|
const isMain = i === 0 || serverData.isMain === true;
|
||||||
|
|
||||||
|
// Se este personagem será main e não é o primeiro, desmarcar outros
|
||||||
|
if (isMain && i > 0) {
|
||||||
|
await tx.userCharacter.updateMany({
|
||||||
|
where: { user_id: userData.userId },
|
||||||
|
data: { is_main: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserir personagem
|
||||||
|
const character = await tx.userCharacter.create({
|
||||||
|
data: {
|
||||||
|
user_id: userData.userId,
|
||||||
|
character_name: serverData.characterName,
|
||||||
|
server_name: serverData.serverName,
|
||||||
|
is_main: isMain,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdCharacters.push({
|
||||||
|
characterName: character.character_name,
|
||||||
|
serverName: character.server_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrementar total_users dos servidores (dentro da transação)
|
||||||
|
await tx.server.updateMany({
|
||||||
|
where: {
|
||||||
|
server_name: { in: uniqueServerNames },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
total_users: { increment: 1 },
|
||||||
|
last_activity_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return userData;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gerar tokens
|
||||||
|
const defaultTier = await getDefaultSubscriptionTier();
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(newUser.userId, newUser.username, {
|
||||||
|
tierSlug: defaultTier?.slug ?? undefined,
|
||||||
|
tierLevel: defaultTier?.level ?? undefined,
|
||||||
|
});
|
||||||
|
const refreshToken = generateRefreshToken(
|
||||||
|
newUser.userId,
|
||||||
|
newUser.username,
|
||||||
|
{
|
||||||
|
tierSlug: defaultTier?.slug ?? undefined,
|
||||||
|
tierLevel: defaultTier?.level ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Criar sessão no Redis
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
await setSession(sessionId, {
|
||||||
|
userId: newUser.userId,
|
||||||
|
username: newUser.username,
|
||||||
|
email: newUser.email,
|
||||||
|
accessToken,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
subscriptionTierId: defaultTier?.subscription_tier_id ?? null,
|
||||||
|
subscriptionTierSlug: defaultTier?.slug ?? null,
|
||||||
|
subscriptionTierName: defaultTier?.name ?? null,
|
||||||
|
subscriptionTierLevel: defaultTier?.level ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar resposta
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
userId: newUser.userId,
|
||||||
|
username: newUser.username,
|
||||||
|
email: newUser.email,
|
||||||
|
subscriptionTier: defaultTier
|
||||||
|
? {
|
||||||
|
id: defaultTier.subscription_tier_id,
|
||||||
|
name: defaultTier.name,
|
||||||
|
slug: defaultTier.slug,
|
||||||
|
level: defaultTier.level,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
accessToken,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adicionar refresh token e sessionId como cookies httpOnly
|
||||||
|
response.cookies.set("refreshToken", refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 dias
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set("sessionId", sessionId, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 dias
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Usuário registrado com sucesso", {
|
||||||
|
userId: newUser.userId,
|
||||||
|
username: newUser.username,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao registrar usuário", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { verifyAccessToken } from "@/lib/auth/jwt";
|
||||||
|
import { getMainCharacter } from "@/lib/prisma/queries/characters";
|
||||||
|
import {
|
||||||
|
getDefaultSubscriptionTier,
|
||||||
|
getSubscriptionTierById,
|
||||||
|
} from "@/lib/prisma/queries/subscription-tiers";
|
||||||
|
import { getUserById } from "@/lib/prisma/queries/users";
|
||||||
|
import {
|
||||||
|
deleteSession,
|
||||||
|
getSession,
|
||||||
|
refreshSession,
|
||||||
|
setSession,
|
||||||
|
} from "@/lib/redis/session";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auth/session
|
||||||
|
* Verifica e retorna dados da sessão atual
|
||||||
|
*/
|
||||||
|
export async function GET(_request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Buscar sessionId do cookie
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = cookieStore.get("sessionId")?.value;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: "Nenhuma sessão encontrada",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar sessão no Redis
|
||||||
|
let session = await getSession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
// Limpar cookie se sessão não existe
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: "Sessão expirada ou inválida",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
|
||||||
|
response.cookies.delete("sessionId");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o access token ainda é válido
|
||||||
|
const tokenPayload = verifyAccessToken(session.accessToken);
|
||||||
|
|
||||||
|
if (!tokenPayload) {
|
||||||
|
// Token expirado - remover sessão
|
||||||
|
await deleteSession(sessionId);
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: "Token expirado",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
|
||||||
|
response.cookies.delete("sessionId");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tierInfo: {
|
||||||
|
subscription_tier_id: number | null;
|
||||||
|
name: string | null;
|
||||||
|
slug: string | null;
|
||||||
|
level: number | null;
|
||||||
|
} | null = {
|
||||||
|
subscription_tier_id: session.subscriptionTierId,
|
||||||
|
name: session.subscriptionTierName,
|
||||||
|
slug: session.subscriptionTierSlug,
|
||||||
|
level: session.subscriptionTierLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
tierInfo.subscription_tier_id === undefined ||
|
||||||
|
tierInfo.subscription_tier_id === null ||
|
||||||
|
tierInfo.slug === null
|
||||||
|
) {
|
||||||
|
const dbUser = await getUserById(session.userId);
|
||||||
|
const fetchedTier = dbUser?.subscription_tier
|
||||||
|
? await getSubscriptionTierById(
|
||||||
|
dbUser.subscription_tier.subscription_tier_id,
|
||||||
|
)
|
||||||
|
: await getDefaultSubscriptionTier();
|
||||||
|
|
||||||
|
tierInfo = fetchedTier
|
||||||
|
? {
|
||||||
|
subscription_tier_id: fetchedTier.subscription_tier_id,
|
||||||
|
name: fetchedTier.name,
|
||||||
|
slug: fetchedTier.slug,
|
||||||
|
level: fetchedTier.level,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
subscription_tier_id: null,
|
||||||
|
name: null,
|
||||||
|
slug: null,
|
||||||
|
level: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
session = {
|
||||||
|
...session,
|
||||||
|
subscriptionTierId: tierInfo.subscription_tier_id,
|
||||||
|
subscriptionTierName: tierInfo.name,
|
||||||
|
subscriptionTierSlug: tierInfo.slug,
|
||||||
|
subscriptionTierLevel: tierInfo.level,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setSession(sessionId, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar TTL da sessão
|
||||||
|
await refreshSession(sessionId);
|
||||||
|
|
||||||
|
// Buscar personagem principal do usuário
|
||||||
|
const mainCharacter = await getMainCharacter(session.userId);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
authenticated: true,
|
||||||
|
user: {
|
||||||
|
userId: session.userId,
|
||||||
|
username: session.username,
|
||||||
|
email: session.email,
|
||||||
|
characterName: mainCharacter?.character_name || null,
|
||||||
|
subscriptionTier: tierInfo
|
||||||
|
? {
|
||||||
|
id: tierInfo.subscription_tier_id,
|
||||||
|
name: tierInfo.name,
|
||||||
|
slug: tierInfo.slug,
|
||||||
|
level: tierInfo.level,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao verificar sessão", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/auth/session
|
||||||
|
* Encerra a sessão atual
|
||||||
|
*/
|
||||||
|
export async function DELETE(_request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = cookieStore.get("sessionId")?.value;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
await deleteSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Sessão encerrada",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
|
||||||
|
response.cookies.delete("sessionId");
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao encerrar sessão", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/chats/[chatId]/messages - Busca mensagens do chat
|
||||||
|
* POST /api/chats/[chatId]/messages - Envia mensagem (fallback se Socket.io não estiver disponível)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { connectMongoDB } from "@/lib/mongodb/connection";
|
||||||
|
import { Chat } from "@/lib/mongodb/models/Chat";
|
||||||
|
import { Message } from "@/lib/mongodb/models/Message";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import {
|
||||||
|
getMessagesSchema,
|
||||||
|
sendMessageSchema,
|
||||||
|
} from "@/lib/validation/chat-schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/chats/[chatId]/messages
|
||||||
|
* Busca mensagens do chat com paginação
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ chatId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { chatId } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const queryValidation = getMessagesSchema.safeParse({
|
||||||
|
page: searchParams.get("page"),
|
||||||
|
limit: searchParams.get("limit"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!queryValidation.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Parâmetros inválidos",
|
||||||
|
details: queryValidation.error.issues,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, limit } = queryValidation.data;
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
|
||||||
|
// Verificar se chat existe e usuário tem acesso
|
||||||
|
const chat = await Chat.findOne({ chat_id: chatId });
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Chat não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para acessar este chat",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar mensagens (não deletadas)
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const messages = await Message.find({
|
||||||
|
chat_id: chatId,
|
||||||
|
deleted_at: null,
|
||||||
|
})
|
||||||
|
.sort({ created_at: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const total = await Message.countDocuments({
|
||||||
|
chat_id: chatId,
|
||||||
|
deleted_at: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Marcar mensagens como lidas
|
||||||
|
await Message.updateMany(
|
||||||
|
{
|
||||||
|
chat_id: chatId,
|
||||||
|
sender_id: { $ne: auth.userId },
|
||||||
|
read_at: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: { read_at: new Date() },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Atualizar contador de não lidas
|
||||||
|
if (chat.buyer_id === auth.userId) {
|
||||||
|
chat.buyer_unread_count = 0;
|
||||||
|
} else {
|
||||||
|
chat.seller_unread_count = 0;
|
||||||
|
}
|
||||||
|
await chat.save();
|
||||||
|
|
||||||
|
// Serializar mensagens (ordem reversa para mostrar mais antigas primeiro)
|
||||||
|
const serializedMessages = messages.reverse().map((msg) => ({
|
||||||
|
_id: msg._id.toString(),
|
||||||
|
chat_id: msg.chat_id,
|
||||||
|
sender_id: msg.sender_id,
|
||||||
|
content: msg.content,
|
||||||
|
created_at: msg.created_at.toISOString(),
|
||||||
|
read_at: msg.read_at?.toISOString() || null,
|
||||||
|
edited_at: msg.edited_at?.toISOString() || null,
|
||||||
|
message_type: msg.message_type,
|
||||||
|
metadata: msg.metadata || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
messages: serializedMessages,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar mensagens", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar mensagens" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/chats/[chatId]/messages
|
||||||
|
* Envia mensagem (fallback se Socket.io não estiver disponível)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ chatId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { chatId } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const validationResult = sendMessageSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Dados inválidos",
|
||||||
|
details: validationResult.error.issues,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content } = validationResult.data;
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
|
||||||
|
// Verificar se chat existe e usuário tem acesso
|
||||||
|
const chat = await Chat.findOne({ chat_id: chatId });
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Chat não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para enviar mensagens neste chat",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar mensagem
|
||||||
|
const message = new Message({
|
||||||
|
chat_id: chatId,
|
||||||
|
sender_id: auth.userId,
|
||||||
|
content,
|
||||||
|
message_type: "text",
|
||||||
|
});
|
||||||
|
|
||||||
|
await message.save();
|
||||||
|
|
||||||
|
// Atualizar chat
|
||||||
|
chat.last_message_at = new Date();
|
||||||
|
chat.last_message_preview = content.substring(0, 100);
|
||||||
|
|
||||||
|
if (chat.buyer_id === auth.userId) {
|
||||||
|
chat.seller_unread_count += 1;
|
||||||
|
} else {
|
||||||
|
chat.buyer_unread_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await chat.save();
|
||||||
|
|
||||||
|
// Tentar enviar via Socket.io se disponível
|
||||||
|
try {
|
||||||
|
const { getSocketIO } = await import("@/lib/socket/server");
|
||||||
|
const io = getSocketIO();
|
||||||
|
if (io) {
|
||||||
|
io.to(`chat:${chatId}`).emit("new_message", {
|
||||||
|
message: {
|
||||||
|
_id: message._id.toString(),
|
||||||
|
chat_id: message.chat_id,
|
||||||
|
sender_id: message.sender_id,
|
||||||
|
content: message.content,
|
||||||
|
created_at: message.created_at.toISOString(),
|
||||||
|
read_at: message.read_at?.toISOString() || null,
|
||||||
|
message_type: message.message_type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_socketError) {
|
||||||
|
// Socket.io não disponível, mensagem já foi salva
|
||||||
|
secureLogger.warn("Socket.io não disponível, mensagem salva via REST", {
|
||||||
|
chatId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: {
|
||||||
|
_id: message._id.toString(),
|
||||||
|
chat_id: message.chat_id,
|
||||||
|
sender_id: message.sender_id,
|
||||||
|
content: message.content,
|
||||||
|
created_at: message.created_at.toISOString(),
|
||||||
|
read_at: message.read_at?.toISOString() || null,
|
||||||
|
message_type: message.message_type,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao enviar mensagem", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao enviar mensagem" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/chats/[chatId] - Busca detalhes de um chat específico
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { connectMongoDB } from "@/lib/mongodb/connection";
|
||||||
|
import { Chat } from "@/lib/mongodb/models/Chat";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ chatId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { chatId } = await params;
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
|
||||||
|
// Buscar chat
|
||||||
|
const chat = await Chat.findOne({ chat_id: chatId });
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Chat não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o usuário pertence ao chat
|
||||||
|
if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para acessar este chat",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
chat: {
|
||||||
|
chat_id: chat.chat_id,
|
||||||
|
listing_id: chat.listing_id,
|
||||||
|
buyer_id: chat.buyer_id,
|
||||||
|
seller_id: chat.seller_id,
|
||||||
|
created_at: chat.created_at.toISOString(),
|
||||||
|
updated_at: chat.updated_at.toISOString(),
|
||||||
|
last_message_at: chat.last_message_at?.toISOString() || null,
|
||||||
|
last_message_preview: chat.last_message_preview,
|
||||||
|
buyer_unread_count: chat.buyer_unread_count,
|
||||||
|
seller_unread_count: chat.seller_unread_count,
|
||||||
|
is_active: chat.is_active,
|
||||||
|
listing_cache: chat.listing_cache,
|
||||||
|
buyer_cache: chat.buyer_cache,
|
||||||
|
seller_cache: chat.seller_cache,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar chat", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar chat" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/chats/mark-all-read
|
||||||
|
* Marca todas as mensagens de todos os chats do usuário como lidas
|
||||||
|
*/
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { connectMongoDB } from "@/lib/mongodb/connection";
|
||||||
|
import { Chat } from "@/lib/mongodb/models/Chat";
|
||||||
|
import { Message } from "@/lib/mongodb/models/Message";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
|
||||||
|
// Buscar todos os chats do usuário
|
||||||
|
const chats = await Chat.find({
|
||||||
|
$or: [{ buyer_id: auth.userId }, { seller_id: auth.userId }],
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Para cada chat, marcar mensagens não lidas como lidas
|
||||||
|
const updatePromises = chats.map(async (chat) => {
|
||||||
|
const chatId = chat.chat_id;
|
||||||
|
|
||||||
|
// Marcar mensagens como lidas
|
||||||
|
await Message.updateMany(
|
||||||
|
{
|
||||||
|
chat_id: chatId,
|
||||||
|
sender_id: { $ne: auth.userId },
|
||||||
|
read_at: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: { read_at: new Date() },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zerar contador de não lidas
|
||||||
|
if (chat.buyer_id === auth.userId) {
|
||||||
|
chat.buyer_unread_count = 0;
|
||||||
|
} else {
|
||||||
|
chat.seller_unread_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chat.save();
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(updatePromises);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Todos os chats marcados como lidos",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao marcar chats como lidos", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao marcar como lido" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/chats - Lista chats do usuário
|
||||||
|
* POST /api/chats - Cria novo chat
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { connectMongoDB } from "@/lib/mongodb/connection";
|
||||||
|
import { Chat } from "@/lib/mongodb/models/Chat";
|
||||||
|
import { getMainCharacter } from "@/lib/prisma/queries/characters";
|
||||||
|
import { getListingById } from "@/lib/prisma/queries/listings";
|
||||||
|
import { getUserById } from "@/lib/prisma/queries/users";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { createChatSchema } from "@/lib/validation/chat-schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/chats
|
||||||
|
* Lista todos os chats do usuário autenticado
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
|
||||||
|
// Buscar todos os chats do usuário (como comprador ou vendedor)
|
||||||
|
const chats = await Chat.find({
|
||||||
|
$or: [{ buyer_id: auth.userId }, { seller_id: auth.userId }],
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
.sort({ updated_at: -1 })
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
// Serializar datas
|
||||||
|
const serializedChats = chats.map((chat) => ({
|
||||||
|
chat_id: chat.chat_id,
|
||||||
|
listing_id: chat.listing_id,
|
||||||
|
buyer_id: chat.buyer_id,
|
||||||
|
seller_id: chat.seller_id,
|
||||||
|
created_at: chat.created_at.toISOString(),
|
||||||
|
updated_at: chat.updated_at.toISOString(),
|
||||||
|
last_message_at: chat.last_message_at?.toISOString() || null,
|
||||||
|
last_message_preview: chat.last_message_preview,
|
||||||
|
buyer_unread_count: chat.buyer_unread_count,
|
||||||
|
seller_unread_count: chat.seller_unread_count,
|
||||||
|
is_active: chat.is_active,
|
||||||
|
listing_cache: chat.listing_cache,
|
||||||
|
buyer_cache: chat.buyer_cache,
|
||||||
|
seller_cache: chat.seller_cache,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
chats: serializedChats,
|
||||||
|
count: serializedChats.length,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar chats", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar chats" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/chats
|
||||||
|
* Cria um novo chat entre comprador e vendedor
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const validationResult = createChatSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Dados inválidos",
|
||||||
|
details: validationResult.error.issues,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { listing_id, buyer_message } = validationResult.data;
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
|
||||||
|
// Buscar anúncio
|
||||||
|
const listing = await getListingById(listing_id);
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se não é o próprio vendedor
|
||||||
|
if (listing.seller_id === auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Você não pode criar um chat com você mesmo" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se anúncio está ativo
|
||||||
|
if (listing.listing_status !== "active") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não está disponível para chat" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerar chat_id único
|
||||||
|
const chat_id = `${listing_id}_${auth.userId}_${listing.seller_id}`;
|
||||||
|
|
||||||
|
// Verificar se chat já existe
|
||||||
|
let chat = await Chat.findOne({ chat_id });
|
||||||
|
|
||||||
|
if (chat) {
|
||||||
|
// Chat já existe, retornar
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
chat: {
|
||||||
|
chat_id: chat.chat_id,
|
||||||
|
listing_id: chat.listing_id,
|
||||||
|
buyer_id: chat.buyer_id,
|
||||||
|
seller_id: chat.seller_id,
|
||||||
|
created_at: chat.created_at.toISOString(),
|
||||||
|
updated_at: chat.updated_at.toISOString(),
|
||||||
|
last_message_at: chat.last_message_at?.toISOString() || null,
|
||||||
|
last_message_preview: chat.last_message_preview,
|
||||||
|
buyer_unread_count: chat.buyer_unread_count,
|
||||||
|
seller_unread_count: chat.seller_unread_count,
|
||||||
|
is_active: chat.is_active,
|
||||||
|
listing_cache: chat.listing_cache,
|
||||||
|
buyer_cache: chat.buyer_cache,
|
||||||
|
seller_cache: chat.seller_cache,
|
||||||
|
},
|
||||||
|
already_exists: true,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar dados do comprador
|
||||||
|
const buyer = await getUserById(auth.userId);
|
||||||
|
if (!buyer) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Usuário não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buyerCharacter = await getMainCharacter(auth.userId);
|
||||||
|
|
||||||
|
// Buscar dados do vendedor
|
||||||
|
const seller = await getUserById(listing.seller_id);
|
||||||
|
if (!seller) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Vendedor não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellerCharacter = await getMainCharacter(listing.seller_id);
|
||||||
|
|
||||||
|
// Criar chat
|
||||||
|
chat = new Chat({
|
||||||
|
chat_id,
|
||||||
|
listing_id,
|
||||||
|
buyer_id: auth.userId,
|
||||||
|
seller_id: listing.seller_id,
|
||||||
|
listing_cache: {
|
||||||
|
item_name: listing.item_name,
|
||||||
|
item_image_url: null, // Imagem será obtida via item_id se necessário
|
||||||
|
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
|
||||||
|
price_tibia_coins: listing.price_tibia_coins || null,
|
||||||
|
price_gold_coins: listing.price_gold_coins
|
||||||
|
? Number(listing.price_gold_coins)
|
||||||
|
: null,
|
||||||
|
server_name: listing.server_name,
|
||||||
|
listing_status: listing.listing_status,
|
||||||
|
},
|
||||||
|
buyer_cache: {
|
||||||
|
user_id: buyer.user_id,
|
||||||
|
username: buyer.username,
|
||||||
|
character_name: buyerCharacter?.character_name || null,
|
||||||
|
},
|
||||||
|
seller_cache: {
|
||||||
|
user_id: seller.user_id,
|
||||||
|
username: seller.username,
|
||||||
|
character_name: sellerCharacter?.character_name || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await chat.save();
|
||||||
|
|
||||||
|
// Se houver mensagem inicial, criar
|
||||||
|
if (buyer_message && buyer_message.trim().length > 0) {
|
||||||
|
const { Message } = await import("@/lib/mongodb/models/Message");
|
||||||
|
const message = new Message({
|
||||||
|
chat_id,
|
||||||
|
sender_id: auth.userId,
|
||||||
|
content: buyer_message.trim(),
|
||||||
|
message_type: "text",
|
||||||
|
});
|
||||||
|
await message.save();
|
||||||
|
|
||||||
|
// Atualizar chat
|
||||||
|
chat.last_message_at = new Date();
|
||||||
|
chat.last_message_preview = buyer_message.substring(0, 100);
|
||||||
|
chat.seller_unread_count = 1;
|
||||||
|
await chat.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
chat: {
|
||||||
|
chat_id: chat.chat_id,
|
||||||
|
listing_id: chat.listing_id,
|
||||||
|
buyer_id: chat.buyer_id,
|
||||||
|
seller_id: chat.seller_id,
|
||||||
|
created_at: chat.created_at.toISOString(),
|
||||||
|
updated_at: chat.updated_at.toISOString(),
|
||||||
|
last_message_at: chat.last_message_at?.toISOString() || null,
|
||||||
|
last_message_preview: chat.last_message_preview,
|
||||||
|
buyer_unread_count: chat.buyer_unread_count,
|
||||||
|
seller_unread_count: chat.seller_unread_count,
|
||||||
|
is_active: chat.is_active,
|
||||||
|
listing_cache: chat.listing_cache,
|
||||||
|
buyer_cache: chat.buyer_cache,
|
||||||
|
seller_cache: chat.seller_cache,
|
||||||
|
},
|
||||||
|
already_exists: false,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao criar chat", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao criar chat" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/chats/unread-count
|
||||||
|
* Retorna contagem de usuários únicos com mensagens não vistas
|
||||||
|
*/
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { connectMongoDB } from "@/lib/mongodb/connection";
|
||||||
|
import { Chat } from "@/lib/mongodb/models/Chat";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
|
||||||
|
// Buscar todos os chats do usuário
|
||||||
|
const chats = await Chat.find({
|
||||||
|
$or: [{ buyer_id: auth.userId }, { seller_id: auth.userId }],
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular contagem de usuários únicos com mensagens não vistas
|
||||||
|
const uniqueUsersWithUnread = new Set<number>();
|
||||||
|
|
||||||
|
chats.forEach((chat) => {
|
||||||
|
let unreadCount = 0;
|
||||||
|
let otherUserId: number;
|
||||||
|
|
||||||
|
if (chat.buyer_id === auth.userId) {
|
||||||
|
unreadCount = chat.buyer_unread_count;
|
||||||
|
otherUserId = chat.seller_id;
|
||||||
|
} else {
|
||||||
|
unreadCount = chat.seller_unread_count;
|
||||||
|
otherUserId = chat.buyer_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se há mensagens não vistas, adicionar o outro usuário ao Set
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
uniqueUsersWithUnread.add(otherUserId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
count: uniqueUsersWithUnread.size,
|
||||||
|
totalUnreadMessages: chats.reduce((total, chat) => {
|
||||||
|
if (chat.buyer_id === auth.userId) {
|
||||||
|
return total + chat.buyer_unread_count;
|
||||||
|
}
|
||||||
|
return total + chat.seller_unread_count;
|
||||||
|
}, 0),
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar contagem de chats não lidos", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar contagem" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { abacatePay } from "@/lib/abacatepay";
|
import { abacatePay } from "@/lib/abacatepay";
|
||||||
import { DONATION_PRODUCTS, DonorData } from "@/types/donation";
|
|
||||||
import { createDonationSchema } from "@/lib/validation-schemas";
|
|
||||||
import {
|
import {
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
getClientIP,
|
|
||||||
createRateLimitResponse,
|
createRateLimitResponse,
|
||||||
|
getClientIP,
|
||||||
} from "@/lib/rate-limiter";
|
} from "@/lib/rate-limiter";
|
||||||
import { secureLogger } from "@/lib/secure-logger";
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { createDonationSchema } from "@/lib/validation-schemas";
|
||||||
|
import { DONATION_PRODUCTS } from "@/types/donation";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
|
|||||||
error: "Dados de entrada inválidos",
|
error: "Dados de entrada inválidos",
|
||||||
details: errors,
|
details: errors,
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ export async function POST(request: NextRequest) {
|
|||||||
if (validatedData.tier === "custom") {
|
if (validatedData.tier === "custom") {
|
||||||
// Converter para centavos
|
// Converter para centavos
|
||||||
finalAmount = Math.round(validatedData.customAmount! * 100);
|
finalAmount = Math.round(validatedData.customAmount! * 100);
|
||||||
finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount!.toFixed(
|
finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount?.toFixed(
|
||||||
2
|
2,
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
secureLogger.info("💰 Valor customizado calculado", {
|
secureLogger.info("💰 Valor customizado calculado", {
|
||||||
@@ -161,7 +161,7 @@ export async function POST(request: NextRequest) {
|
|||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "Erro interno",
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { getPaymentStatus } from "@/lib/payment-cache";
|
import { getPaymentStatus } from "@/lib/payment-cache";
|
||||||
import {
|
import {
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
getClientIP,
|
|
||||||
createRateLimitResponse,
|
createRateLimitResponse,
|
||||||
|
getClientIP,
|
||||||
} from "@/lib/rate-limiter";
|
} from "@/lib/rate-limiter";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!pixId) {
|
if (!pixId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "pixId é obrigatório" },
|
{ error: "pixId é obrigatório" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export async function GET(request: NextRequest) {
|
|||||||
console.error("Erro ao verificar status do pagamento:", error);
|
console.error("Erro ao verificar status do pagamento:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Erro ao verificar status" },
|
{ error: "Erro ao verificar status" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { abacatePay } from "@/lib/abacatepay";
|
import { abacatePay } from "@/lib/abacatepay";
|
||||||
import {
|
import {
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
getClientIP,
|
|
||||||
createRateLimitResponse,
|
createRateLimitResponse,
|
||||||
|
getClientIP,
|
||||||
} from "@/lib/rate-limiter";
|
} from "@/lib/rate-limiter";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!billingId) {
|
if (!billingId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "ID da cobrança é obrigatório" },
|
{ success: false, error: "ID da cobrança é obrigatório" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export async function GET(request: NextRequest) {
|
|||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : "Erro ao verificar cobrança",
|
error instanceof Error ? error.message : "Erro ao verificar cobrança",
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { getItemByIdUnified } from "@/lib/db/queries/items";
|
||||||
|
import { buildItemImagePath } from "@/lib/item-images";
|
||||||
|
import { getItemById } from "@/lib/items-db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const relPath = searchParams.get("path") || "";
|
||||||
|
const itemId = searchParams.get("item");
|
||||||
|
const itemName = searchParams.get("name"); // Nome do item para validação (opcional)
|
||||||
|
|
||||||
|
let imagePath: string | null = null;
|
||||||
|
|
||||||
|
// Se foi fornecido um item ID, buscar o item no banco
|
||||||
|
if (itemId) {
|
||||||
|
try {
|
||||||
|
const itemIdNum = parseInt(itemId, 10);
|
||||||
|
if (!Number.isNaN(itemIdNum)) {
|
||||||
|
// Tentar primeiro buscar na tabela unificada PostgreSQL
|
||||||
|
// Se a tabela não existir, retornará null silenciosamente
|
||||||
|
let item: any = null;
|
||||||
|
item = await getItemByIdUnified(itemIdNum);
|
||||||
|
|
||||||
|
// Se encontrou na tabela unificada, validar o nome se fornecido
|
||||||
|
if (item && itemName && item.item_name) {
|
||||||
|
if (item.item_name.toLowerCase() !== itemName.toLowerCase()) {
|
||||||
|
// Nome não bate, ignorar este resultado
|
||||||
|
item = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não encontrou na tabela unificada, tentar SQLite legado
|
||||||
|
// Passar itemName para buscar por ID E NOME (evita ambiguidade)
|
||||||
|
if (!item) {
|
||||||
|
item = getItemById(itemIdNum, itemName || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
// PRIORIDADE: 1. image_local_path (local), 2. image_url (externa), 3. image (fallback)
|
||||||
|
const imageLocalPath = item.image_local_path || "";
|
||||||
|
const imageUrl = item.image_url || "";
|
||||||
|
const image = item.image || "";
|
||||||
|
|
||||||
|
// Tentar primeiro a imagem local
|
||||||
|
if (imageLocalPath) {
|
||||||
|
const normalizedPath = imageLocalPath.replace(/\\/g, "/");
|
||||||
|
const category = (
|
||||||
|
item.categoria ||
|
||||||
|
item.category ||
|
||||||
|
"other"
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
|
// Se já for um caminho completo (começa com data/), usar diretamente
|
||||||
|
if (normalizedPath.startsWith("data/")) {
|
||||||
|
imagePath = normalizedPath;
|
||||||
|
} else {
|
||||||
|
// Construir o caminho usando a função helper
|
||||||
|
imagePath = buildItemImagePath(category, normalizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o arquivo existe antes de usar
|
||||||
|
if (imagePath) {
|
||||||
|
const baseDir = path.join(process.cwd(), "data", "images");
|
||||||
|
const resolved = path.join(process.cwd(), imagePath);
|
||||||
|
|
||||||
|
if (resolved.startsWith(baseDir)) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(resolved);
|
||||||
|
// Arquivo existe, usar este caminho
|
||||||
|
} catch {
|
||||||
|
// Arquivo não existe, tentar fallback externo
|
||||||
|
imagePath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não encontrou imagem local ou não existe, usar URL externa como fallback
|
||||||
|
if (!imagePath && (imageUrl || image)) {
|
||||||
|
const externalUrl = imageUrl || image;
|
||||||
|
if (
|
||||||
|
externalUrl.startsWith("http://") ||
|
||||||
|
externalUrl.startsWith("https://")
|
||||||
|
) {
|
||||||
|
return Response.redirect(externalUrl, 302);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[api/images] Erro ao buscar item:", error);
|
||||||
|
}
|
||||||
|
} else if (relPath) {
|
||||||
|
// Se foi fornecido um path, usar diretamente
|
||||||
|
imagePath = relPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imagePath) {
|
||||||
|
// Se não foi possível construir o caminho, retornar 404 ao invés de 400
|
||||||
|
// para evitar erros visíveis no console do navegador
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permitir apenas caminhos sob data/images para segurança
|
||||||
|
const baseDir = path.join(process.cwd(), "data", "images");
|
||||||
|
const resolved = path.join(process.cwd(), imagePath);
|
||||||
|
|
||||||
|
if (!resolved.startsWith(baseDir)) {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await fs.promises.readFile(resolved);
|
||||||
|
const ext = path.extname(resolved).toLowerCase();
|
||||||
|
const contentType =
|
||||||
|
ext === ".gif"
|
||||||
|
? "image/gif"
|
||||||
|
: ext === ".png"
|
||||||
|
? "image/png"
|
||||||
|
: ext === ".webp"
|
||||||
|
? "image/webp"
|
||||||
|
: ext === ".jpg" || ext === ".jpeg"
|
||||||
|
? "image/jpeg"
|
||||||
|
: "application/octet-stream";
|
||||||
|
return new Response(file, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": contentType },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[api/images] Erro ao ler arquivo:", error);
|
||||||
|
// Retornar 404 silenciosamente para não gerar erros no console
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma/app";
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const categories = await prisma.item.findMany({
|
||||||
|
distinct: ["category"],
|
||||||
|
select: { category: true },
|
||||||
|
orderBy: { category: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
categories: categories
|
||||||
|
.map((row) => row.category)
|
||||||
|
.filter((category): category is string => Boolean(category)),
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return Response.json({ error: e?.message || "Erro" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { buildPublicItemImageUrl } from "@/lib/item-images";
|
||||||
|
import { type ItemSort, searchItems } from "@/lib/prisma/queries/items";
|
||||||
|
|
||||||
|
function parseArray(param: string | null): string[] {
|
||||||
|
if (!param) return [];
|
||||||
|
return param
|
||||||
|
.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedSorts: ItemSort[] = [
|
||||||
|
"name",
|
||||||
|
"level-asc",
|
||||||
|
"level-desc",
|
||||||
|
"weight-asc",
|
||||||
|
"weight-desc",
|
||||||
|
"category",
|
||||||
|
"updated-desc",
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = searchParams.get("q") || undefined;
|
||||||
|
const categories = parseArray(searchParams.get("category"));
|
||||||
|
const rarities = parseArray(searchParams.get("rarity"));
|
||||||
|
const server = searchParams.get("server") || undefined;
|
||||||
|
const sortParamRaw = (searchParams.get("sort") || "name").toLowerCase();
|
||||||
|
const sortParam = (allowedSorts.find((sort) => sort === sortParamRaw) ||
|
||||||
|
"name") as ItemSort;
|
||||||
|
const page = Number(searchParams.get("page") || "1");
|
||||||
|
const pageSize = Number(searchParams.get("pageSize") || "24");
|
||||||
|
|
||||||
|
const { items, total } = await searchItems({
|
||||||
|
q,
|
||||||
|
category: categories[0],
|
||||||
|
rarity: rarities[0],
|
||||||
|
server,
|
||||||
|
sort: sortParam,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normaliza campos esperados pelo cliente, preservando compatibilidade
|
||||||
|
const itemsWithImage = items.map((item) => {
|
||||||
|
const imgRaw = (item.image_url || item.image_local_path || "").toString();
|
||||||
|
const categorySafe = item.category || "other";
|
||||||
|
|
||||||
|
const idSafe = typeof item.item_id === "number" ? item.item_id : undefined;
|
||||||
|
const nameSafe =
|
||||||
|
typeof (item as any).item_name === "string" && (item as any).item_name
|
||||||
|
? (item as any).item_name
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
...item,
|
||||||
|
// Aliases usados pelos clientes públicos (Library, etc.)
|
||||||
|
id: idSafe,
|
||||||
|
name: nameSafe,
|
||||||
|
category: categorySafe,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(imgRaw)) {
|
||||||
|
return { ...base, image_url: imgRaw, image: imgRaw };
|
||||||
|
}
|
||||||
|
if (imgRaw.startsWith("data/")) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
image_url: imgRaw,
|
||||||
|
image: `/api/images?path=${encodeURIComponent(imgRaw)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
image_url: item.image_url,
|
||||||
|
image: buildPublicItemImageUrl(categorySafe, imgRaw),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
items: itemsWithImage,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
sort: sortParam,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[api/items] Erro:", error?.message, error?.stack);
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "Falha ao consultar itens",
|
||||||
|
message: error?.message || "Erro desconhecido",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/listings/:id/activate
|
||||||
|
* Reativa um anúncio pausado (apenas dono)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Autenticação obrigatória
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o anúncio existe e pertence ao usuário
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Anúncio não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.seller_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para reativar este anúncio",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.listing_status !== "cancelled") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Apenas anúncios pausados podem ser reativados",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reativar anúncio
|
||||||
|
const updated = await updateListing(listingId, {
|
||||||
|
listing_status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao reativar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.info("✅ Anúncio reativado", {
|
||||||
|
listingId,
|
||||||
|
userId: auth.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
listing: updated,
|
||||||
|
message: "Anúncio reativado com sucesso",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao reativar anúncio", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao reativar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { placeBid } from "@/lib/prisma/queries/listings";
|
||||||
|
import { createNotification } from "@/lib/prisma/queries/notifications";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/listings/[id]/bid
|
||||||
|
* Coloca um lance em um leilão
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de anúncio inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { amount, payment_method } = body;
|
||||||
|
|
||||||
|
if (!amount || typeof amount !== "number" || amount <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Valor do lance é obrigatório e deve ser maior que zero",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payment_method || !["pix", "tc", "gp"].includes(payment_method)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Método de pagamento inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colocar lance
|
||||||
|
const result = await placeBid(
|
||||||
|
listingId,
|
||||||
|
auth.userId,
|
||||||
|
amount,
|
||||||
|
payment_method,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result.error },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantir existência do anúncio e vendedor antes de notificar
|
||||||
|
const listing = result.listing;
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não encontrado após registrar o lance" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof listing.seller_id !== "number") {
|
||||||
|
secureLogger.warn("SellerId inválido ao criar notificação de lance", {
|
||||||
|
listingId,
|
||||||
|
sellerId: listing.seller_id,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Vendedor inválido para notificação" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar notificação para o vendedor
|
||||||
|
await createNotification({
|
||||||
|
user_id: listing.seller_id,
|
||||||
|
notification_type: "bid",
|
||||||
|
title: "Novo lance!",
|
||||||
|
message: `Novo lance de R$ ${amount.toFixed(2)} no item "${listing.item_name}"`,
|
||||||
|
related_id: listingId,
|
||||||
|
send_whatsapp: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se havia um lanceador anterior, notificar que foi superado
|
||||||
|
if (result.previousBidderId) {
|
||||||
|
await createNotification({
|
||||||
|
user_id: result.previousBidderId,
|
||||||
|
notification_type: "bid",
|
||||||
|
title: "Lance superado",
|
||||||
|
message: `Seu lance foi superado no item "${listing.item_name}"`,
|
||||||
|
related_id: listingId,
|
||||||
|
send_whatsapp: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar telemetria
|
||||||
|
const { trackEvent } = await import("@/lib/prisma/queries/telemetry");
|
||||||
|
await trackEvent({
|
||||||
|
event_type: "bid_placed",
|
||||||
|
ad_id: listingId,
|
||||||
|
user_id: auth.userId,
|
||||||
|
metadata: {
|
||||||
|
bid_amount: amount,
|
||||||
|
payment_method: payment_method,
|
||||||
|
previous_bid: listing.current_bid_amount
|
||||||
|
? Number(listing.current_bid_amount)
|
||||||
|
: null,
|
||||||
|
min_increment: listing.min_bid_increment
|
||||||
|
? Number(listing.min_bid_increment)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Lance realizado com sucesso", {
|
||||||
|
userId: auth.userId,
|
||||||
|
listingId,
|
||||||
|
amount,
|
||||||
|
paymentMethod: payment_method,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
listing: result.listing,
|
||||||
|
message: "Lance realizado com sucesso",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao processar lance", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao processar lance" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { prisma } from "@/lib/prisma/app";
|
||||||
|
import { getListingById } from "@/lib/prisma/queries/listings";
|
||||||
|
import { trackEvent } from "@/lib/prisma/queries/telemetry";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/listings/[id]/bids
|
||||||
|
* Lista lances de um anúncio
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de anúncio inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar listing para verificar se é leilão
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listing.is_auction) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Este anúncio não é um leilão" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar parâmetros de filtro
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10);
|
||||||
|
|
||||||
|
// Buscar lances
|
||||||
|
const bids = await prisma.bid.findMany({
|
||||||
|
where: {
|
||||||
|
listing_id: listingId,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
bidder: {
|
||||||
|
select: {
|
||||||
|
user_id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
created_at: "desc",
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatar resposta
|
||||||
|
const formattedBids = bids.map((bid) => ({
|
||||||
|
bid_id: bid.bid_id,
|
||||||
|
bidder_id: bid.bidder_id,
|
||||||
|
bidder_name: bid.bidder.username,
|
||||||
|
bid_amount: Number(bid.bid_amount),
|
||||||
|
bid_type: bid.bid_type,
|
||||||
|
created_at: bid.created_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Registrar telemetria
|
||||||
|
await trackEvent({
|
||||||
|
event_type: "bid_viewed",
|
||||||
|
ad_id: listingId,
|
||||||
|
user_id: authResult.userId,
|
||||||
|
metadata: {
|
||||||
|
bids_count: formattedBids.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
bids: formattedBids,
|
||||||
|
total: formattedBids.length,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar histórico de lances", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar histórico de lances" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { isFavorite, toggleFavorite } from "@/lib/prisma/queries/favorites";
|
||||||
|
import { getListingById } from "@/lib/prisma/queries/listings";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/listings/:id/favorite
|
||||||
|
* Favoritar ou desfavoritar um anúncio
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Autenticação obrigatória
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o anúncio existe
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Anúncio não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle favorite
|
||||||
|
const favorited = await toggleFavorite(auth.userId, listingId);
|
||||||
|
|
||||||
|
secureLogger.info(
|
||||||
|
`✅ Anúncio ${favorited ? "favoritado" : "desfavoritado"}`,
|
||||||
|
{
|
||||||
|
listingId,
|
||||||
|
userId: auth.userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
favorited,
|
||||||
|
message: favorited ? "Anúncio favoritado" : "Anúncio desfavoritado",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao favoritar/desfavoritar anúncio", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao favoritar/desfavoritar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/listings/:id/favorite
|
||||||
|
* Verifica se o anúncio está favoritado pelo usuário
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Autenticação obrigatória
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorited = await isFavorite(auth.userId, listingId);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
favorited,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao verificar favorito", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao verificar favorito",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { connectMongoDB } from "@/lib/mongodb/connection";
|
||||||
|
import { Chat } from "@/lib/mongodb/models/Chat";
|
||||||
|
import { Message } from "@/lib/mongodb/models/Message";
|
||||||
|
import { getMainCharacter } from "@/lib/prisma/queries/characters";
|
||||||
|
import { getListingById } from "@/lib/prisma/queries/listings";
|
||||||
|
import { createNotification } from "@/lib/prisma/queries/notifications";
|
||||||
|
import {
|
||||||
|
getPurchaseIntentionById,
|
||||||
|
updatePurchaseIntentionStatus,
|
||||||
|
} from "@/lib/prisma/queries/purchase-intentions";
|
||||||
|
import { getUserById } from "@/lib/prisma/queries/users";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/listings/[id]/intentions/[intentionId]
|
||||||
|
* Busca uma intenção específica
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; intentionId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const { intentionId: intentionIdParam } = await params;
|
||||||
|
const intentionId = parseInt(intentionIdParam, 10);
|
||||||
|
if (Number.isNaN(intentionId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de intenção inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const intention = await getPurchaseIntentionById(intentionId);
|
||||||
|
if (!intention) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Intenção não encontrada" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permissões: vendedor ou comprador
|
||||||
|
if (
|
||||||
|
intention.listing?.seller_id !== authResult.userId &&
|
||||||
|
intention.buyer_id !== authResult.userId
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para ver esta intenção",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
intention,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar intenção", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar intenção" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/listings/[id]/intentions/[intentionId]
|
||||||
|
* Atualiza o status de uma intenção (aceitar/rejeitar)
|
||||||
|
*/
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; intentionId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const { intentionId: intentionIdParam } = await params;
|
||||||
|
const intentionId = parseInt(intentionIdParam, 10);
|
||||||
|
if (Number.isNaN(intentionId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de intenção inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { status, seller_response } = body;
|
||||||
|
const sellerResponse =
|
||||||
|
typeof seller_response === "string" && seller_response.trim().length > 0
|
||||||
|
? seller_response
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!status || !["accepted", "rejected"].includes(status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Status inválido. Use 'accepted' ou 'rejected'",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar intenção
|
||||||
|
const intention = await getPurchaseIntentionById(intentionId);
|
||||||
|
if (!intention) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Intenção não encontrada" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o usuário é o vendedor
|
||||||
|
if (intention.listing?.seller_id !== authResult.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Apenas o vendedor pode aceitar ou rejeitar intenções",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se a intenção ainda está pendente
|
||||||
|
if (intention.status !== "pending") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: `Esta intenção já foi ${intention.status}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o anúncio ainda está disponível (ativo ou em escrow)
|
||||||
|
const listing = await getListingById(intention.listing_id);
|
||||||
|
if (
|
||||||
|
!listing ||
|
||||||
|
(listing.listing_status !== "active" &&
|
||||||
|
listing.listing_status !== "in_escrow")
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não está mais disponível" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar status da intenção (não altera status do listing)
|
||||||
|
const updatedIntention = await updatePurchaseIntentionStatus(
|
||||||
|
intentionId,
|
||||||
|
status,
|
||||||
|
sellerResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendSellerResponseToChat({
|
||||||
|
listing,
|
||||||
|
intention,
|
||||||
|
status,
|
||||||
|
sellerResponse,
|
||||||
|
sellerId: authResult.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar notificação para o comprador
|
||||||
|
await createNotification({
|
||||||
|
user_id: intention.buyer_id,
|
||||||
|
notification_type: status === "accepted" ? "purchase" : "system",
|
||||||
|
title: status === "accepted" ? "Intenção aceita!" : "Intenção rejeitada",
|
||||||
|
message:
|
||||||
|
status === "accepted"
|
||||||
|
? `O vendedor aceitou sua intenção de compra do item "${intention.listing?.item_name}"`
|
||||||
|
: `O vendedor rejeitou sua intenção de compra do item "${intention.listing?.item_name}"`,
|
||||||
|
related_id: intention.listing_id,
|
||||||
|
send_whatsapp: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info(
|
||||||
|
`✅ Intenção ${status === "accepted" ? "aceita" : "rejeitada"}`,
|
||||||
|
{
|
||||||
|
userId: authResult.userId,
|
||||||
|
intentionId,
|
||||||
|
listingId: intention.listing_id,
|
||||||
|
buyerId: intention.buyer_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
intention: updatedIntention,
|
||||||
|
message: `Intenção ${status === "accepted" ? "aceita" : "rejeitada"} com sucesso`,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao atualizar intenção", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao atualizar intenção" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSellerResponseToChat({
|
||||||
|
listing,
|
||||||
|
intention,
|
||||||
|
status,
|
||||||
|
sellerResponse,
|
||||||
|
sellerId,
|
||||||
|
}: {
|
||||||
|
listing: Awaited<ReturnType<typeof getListingById>>;
|
||||||
|
intention: Exclude<
|
||||||
|
Awaited<ReturnType<typeof getPurchaseIntentionById>>,
|
||||||
|
null
|
||||||
|
>;
|
||||||
|
status: "accepted" | "rejected";
|
||||||
|
sellerResponse?: string;
|
||||||
|
sellerId: number;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
if (!listing) return;
|
||||||
|
|
||||||
|
const contentBase = sellerResponse?.trim()
|
||||||
|
? sellerResponse.trim()
|
||||||
|
: status === "accepted"
|
||||||
|
? "O vendedor aceitou sua intenção de compra."
|
||||||
|
: "O vendedor rejeitou sua intenção de compra.";
|
||||||
|
|
||||||
|
const content = `[Intenções] ${contentBase}`;
|
||||||
|
if (!content.trim()) return;
|
||||||
|
|
||||||
|
const chatId = `${listing.listing_id}_${intention.buyer_id}_${listing.seller_id}`;
|
||||||
|
|
||||||
|
await connectMongoDB();
|
||||||
|
let chat = await Chat.findOne({ chat_id: chatId });
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
const [buyer, seller, buyerCharacter, sellerCharacter] =
|
||||||
|
await Promise.all([
|
||||||
|
getUserById(intention.buyer_id),
|
||||||
|
getUserById(listing.seller_id),
|
||||||
|
getMainCharacter(intention.buyer_id),
|
||||||
|
getMainCharacter(listing.seller_id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
chat = new Chat({
|
||||||
|
chat_id: chatId,
|
||||||
|
listing_id: listing.listing_id,
|
||||||
|
buyer_id: intention.buyer_id,
|
||||||
|
seller_id: listing.seller_id,
|
||||||
|
buyer_unread_count: 0,
|
||||||
|
seller_unread_count: 0,
|
||||||
|
is_active: listing.listing_status === "active",
|
||||||
|
listing_cache: {
|
||||||
|
item_name: listing.item_name,
|
||||||
|
item_image_url: null,
|
||||||
|
price_pix: listing.price_pix ?? null,
|
||||||
|
price_tibia_coins: listing.price_tibia_coins ?? null,
|
||||||
|
price_gold_coins: listing.price_gold_coins ?? null,
|
||||||
|
server_name: listing.server_name,
|
||||||
|
listing_status: listing.listing_status,
|
||||||
|
},
|
||||||
|
buyer_cache: {
|
||||||
|
user_id: buyer?.user_id ?? intention.buyer_id,
|
||||||
|
username: buyer?.username ?? intention.buyer?.username ?? "Comprador",
|
||||||
|
character_name: buyerCharacter?.character_name || null,
|
||||||
|
},
|
||||||
|
seller_cache: {
|
||||||
|
user_id: seller?.user_id ?? listing.seller_id,
|
||||||
|
username: seller?.username ?? "Vendedor",
|
||||||
|
character_name: sellerCharacter?.character_name || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await chat.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = new Message({
|
||||||
|
chat_id: chat.chat_id,
|
||||||
|
sender_id: sellerId,
|
||||||
|
content,
|
||||||
|
message_type: "text",
|
||||||
|
});
|
||||||
|
await message.save();
|
||||||
|
|
||||||
|
chat.last_message_at = new Date();
|
||||||
|
chat.last_message_preview = content.substring(0, 100);
|
||||||
|
chat.buyer_unread_count = (chat.buyer_unread_count || 0) + 1;
|
||||||
|
chat.is_active = listing.listing_status === "active";
|
||||||
|
await chat.save();
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Falha ao enviar resposta para o chat", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { getListingById } from "@/lib/prisma/queries/listings";
|
||||||
|
import { createNotification } from "@/lib/prisma/queries/notifications";
|
||||||
|
import {
|
||||||
|
createPurchaseIntention,
|
||||||
|
getPendingIntentionByBuyerAndListing,
|
||||||
|
getPurchaseIntentionsByListing,
|
||||||
|
} from "@/lib/prisma/queries/purchase-intentions";
|
||||||
|
import { trackEvent } from "@/lib/prisma/queries/telemetry";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/listings/[id]/intentions
|
||||||
|
* Cria uma nova intenção de compra
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de anúncio inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
payment_method,
|
||||||
|
buyer_message,
|
||||||
|
custom_offer_amount,
|
||||||
|
custom_offer_currency,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!payment_method ||
|
||||||
|
!["pix", "tibia_coins", "gold_coins", "trade"].includes(payment_method)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Método de pagamento inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar listing
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se não é o vendedor
|
||||||
|
if (listing.seller_id === auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Você não pode criar uma intenção de compra para seu próprio anúncio",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se está ativo
|
||||||
|
if (listing.listing_status !== "active") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não está disponível para compra" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se é leilão (leilões não podem ter intenções, apenas lances)
|
||||||
|
if (listing.is_auction) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Este item está em leilão. Use a funcionalidade de lance.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar método de pagamento disponível
|
||||||
|
const availableMethods: string[] = [];
|
||||||
|
if (listing.price_pix) availableMethods.push("pix");
|
||||||
|
if (listing.price_tibia_coins) availableMethods.push("tibia_coins");
|
||||||
|
if (listing.price_gold_coins) availableMethods.push("gold_coins");
|
||||||
|
if (listing.trade_item_id) availableMethods.push("trade");
|
||||||
|
|
||||||
|
if (!availableMethods.includes(payment_method)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
|
||||||
|
available_methods: availableMethods,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedOfferValue: number | null = null;
|
||||||
|
let normalizedOfferCurrency: string | null = null;
|
||||||
|
|
||||||
|
if (custom_offer_amount !== undefined && custom_offer_amount !== null) {
|
||||||
|
if (!["pix", "tibia_coins", "gold_coins"].includes(payment_method)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Ofertas personalizadas só são permitidas para métodos monetários (PIX, Tibia Coins ou Gold Coins).",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericOffer = Number(custom_offer_amount);
|
||||||
|
if (Number.isNaN(numericOffer) || numericOffer <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Valor da oferta personalizada deve ser numérico e maior que zero.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedOfferCurrency =
|
||||||
|
custom_offer_currency &&
|
||||||
|
["pix", "tibia_coins", "gold_coins"].includes(custom_offer_currency)
|
||||||
|
? custom_offer_currency
|
||||||
|
: payment_method;
|
||||||
|
|
||||||
|
normalizedOfferValue =
|
||||||
|
normalizedOfferCurrency === "pix"
|
||||||
|
? Number(numericOffer.toFixed(2))
|
||||||
|
: Math.floor(numericOffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já existe uma intenção pendente
|
||||||
|
const existingIntention = await getPendingIntentionByBuyerAndListing(
|
||||||
|
listingId,
|
||||||
|
auth.userId,
|
||||||
|
);
|
||||||
|
if (existingIntention) {
|
||||||
|
// Se já existe e ainda pode trocar o método
|
||||||
|
if (existingIntention.payment_method_change_count < 1) {
|
||||||
|
// Permite atualizar o método via endpoint específico
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Você já possui uma intenção de compra pendente para este anúncio",
|
||||||
|
existing_intention_id: existingIntention.intention_id,
|
||||||
|
can_change_payment_method: true,
|
||||||
|
payment_method_change_count:
|
||||||
|
existingIntention.payment_method_change_count,
|
||||||
|
message:
|
||||||
|
"Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Já atingiu o limite de trocas
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Você já possui uma intenção de compra pendente para este anúncio e atingiu o limite de trocas de método de pagamento",
|
||||||
|
existing_intention_id: existingIntention.intention_id,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar intenção de compra
|
||||||
|
const intention = await createPurchaseIntention({
|
||||||
|
listing_id: listingId,
|
||||||
|
buyer_id: auth.userId,
|
||||||
|
payment_method: payment_method as any,
|
||||||
|
buyer_message: buyer_message || undefined,
|
||||||
|
custom_offer_amount: normalizedOfferValue ?? undefined,
|
||||||
|
custom_offer_currency: normalizedOfferCurrency as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar notificação para o vendedor
|
||||||
|
await createNotification({
|
||||||
|
user_id: listing.seller_id,
|
||||||
|
notification_type: "purchase",
|
||||||
|
title: "Nova intenção de compra!",
|
||||||
|
message:
|
||||||
|
normalizedOfferValue && normalizedOfferCurrency
|
||||||
|
? `Alguém fez uma oferta de ${normalizedOfferCurrency === "pix" ? `R$ ${normalizedOfferValue.toFixed(2)}` : `${normalizedOfferValue.toLocaleString()} ${normalizedOfferCurrency === "tibia_coins" ? "TC" : "GP"}`} pelo item "${listing.item_name}".`
|
||||||
|
: `Alguém manifestou interesse em comprar seu item "${listing.item_name}" por ${payment_method.toUpperCase()}`,
|
||||||
|
related_id: listingId,
|
||||||
|
send_whatsapp: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Registrar telemetria
|
||||||
|
await trackEvent({
|
||||||
|
event_type: "purchase_intention_created",
|
||||||
|
ad_id: listingId,
|
||||||
|
user_id: auth.userId,
|
||||||
|
metadata: {
|
||||||
|
payment_method,
|
||||||
|
listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null,
|
||||||
|
listing_price_tc: listing.price_tibia_coins || null,
|
||||||
|
listing_price_gp: listing.price_gold_coins
|
||||||
|
? Number(listing.price_gold_coins)
|
||||||
|
: null,
|
||||||
|
custom_offer_amount: normalizedOfferValue,
|
||||||
|
custom_offer_currency: normalizedOfferCurrency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Intenção de compra criada com sucesso", {
|
||||||
|
userId: auth.userId,
|
||||||
|
listingId,
|
||||||
|
intentionId: intention.intention_id,
|
||||||
|
paymentMethod: payment_method,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
intention,
|
||||||
|
message:
|
||||||
|
"Intenção de compra criada com sucesso. O vendedor será notificado.",
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao criar intenção de compra", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao criar intenção de compra" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/listings/[id]/intentions
|
||||||
|
* Lista intenções de compra de um anúncio (apenas vendedor)
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de anúncio inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar listing para verificar se o usuário é o vendedor
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apenas o vendedor pode ver as intenções
|
||||||
|
if (listing.seller_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para ver as intenções deste anúncio",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar parâmetros de filtro
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get("status") as any;
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10);
|
||||||
|
|
||||||
|
// Buscar intenções
|
||||||
|
const intentions = await getPurchaseIntentionsByListing(listingId, {
|
||||||
|
status,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Registrar telemetria
|
||||||
|
await trackEvent({
|
||||||
|
event_type: "purchase_intention_viewed",
|
||||||
|
ad_id: listingId,
|
||||||
|
user_id: auth.userId,
|
||||||
|
metadata: {
|
||||||
|
intentions_count: intentions.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
intentions,
|
||||||
|
count: intentions.length,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar intenções de compra", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar intenções de compra" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { getListingById } from "@/lib/prisma/queries/listings";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/listings/:id/mark-sold
|
||||||
|
* Marca um anúncio como vendido (apenas dono)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Autenticação obrigatória
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o anúncio existe e pertence ao usuário
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Anúncio não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.seller_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para marcar este anúncio como vendido",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o anúncio pode ser marcado como vendido
|
||||||
|
if (
|
||||||
|
listing.listing_status !== "active" &&
|
||||||
|
listing.listing_status !== "in_escrow"
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Apenas anúncios ativos ou em escrow podem ser marcados como vendidos",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcar como vendido e definir sold_at usando Prisma diretamente
|
||||||
|
const { prisma } = await import("@/lib/prisma/app");
|
||||||
|
const updatedListing = await prisma.listing.update({
|
||||||
|
where: { listing_id: listingId },
|
||||||
|
data: {
|
||||||
|
listing_status: "sold",
|
||||||
|
sold_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buscar character name para retornar dados completos
|
||||||
|
const character = await prisma.userCharacter.findFirst({
|
||||||
|
where: {
|
||||||
|
user_id: updatedListing.seller_id,
|
||||||
|
server_name: updatedListing.server_name,
|
||||||
|
is_main: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Converter campos para formato esperado
|
||||||
|
const updated = {
|
||||||
|
...updatedListing,
|
||||||
|
price_pix: updatedListing.price_pix
|
||||||
|
? Number(updatedListing.price_pix)
|
||||||
|
: null,
|
||||||
|
current_bid_amount: updatedListing.current_bid_amount
|
||||||
|
? Number(updatedListing.current_bid_amount)
|
||||||
|
: null,
|
||||||
|
min_bid_increment: updatedListing.min_bid_increment
|
||||||
|
? Number(updatedListing.min_bid_increment)
|
||||||
|
: null,
|
||||||
|
initial_price_pix: updatedListing.initial_price_pix
|
||||||
|
? Number(updatedListing.initial_price_pix)
|
||||||
|
: null,
|
||||||
|
price_gold_coins: updatedListing.price_gold_coins
|
||||||
|
? Number(updatedListing.price_gold_coins)
|
||||||
|
: null,
|
||||||
|
initial_price_gold_coins: updatedListing.initial_price_gold_coins
|
||||||
|
? Number(updatedListing.initial_price_gold_coins)
|
||||||
|
: null,
|
||||||
|
seller_character_name: character?.character_name || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao marcar anúncio como vendido",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.info("✅ Anúncio marcado como vendido", {
|
||||||
|
listingId,
|
||||||
|
userId: auth.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
listing: updated,
|
||||||
|
message: "Anúncio marcado como vendido com sucesso",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao marcar anúncio como vendido", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao marcar anúncio como vendido",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/listings/:id/pause
|
||||||
|
* Pausa um anúncio (apenas dono)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Autenticação obrigatória
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o anúncio existe e pertence ao usuário
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Anúncio não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.seller_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para pausar este anúncio",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.listing_status !== "active") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Apenas anúncios ativos podem ser pausados",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pausar anúncio (usando status 'cancelled' temporariamente, ou podemos criar um novo status 'paused')
|
||||||
|
// Por enquanto, vamos usar 'cancelled' mas manter os dados para poder reativar
|
||||||
|
const updated = await updateListing(listingId, {
|
||||||
|
listing_status: "cancelled",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao pausar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.info("✅ Anúncio pausado", {
|
||||||
|
listingId,
|
||||||
|
userId: auth.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
listing: updated,
|
||||||
|
message: "Anúncio pausado com sucesso",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao pausar anúncio", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao pausar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { getListingById } from "@/lib/prisma/queries/listings";
|
||||||
|
import { createNotification } from "@/lib/prisma/queries/notifications";
|
||||||
|
import {
|
||||||
|
createPurchaseIntention,
|
||||||
|
getPendingIntentionByBuyerAndListing,
|
||||||
|
} from "@/lib/prisma/queries/purchase-intentions";
|
||||||
|
import { trackEvent } from "@/lib/prisma/queries/telemetry";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/listings/[id]/purchase
|
||||||
|
* Cria uma intenção de compra para um item anunciado
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de anúncio inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
payment_method,
|
||||||
|
buyer_message,
|
||||||
|
custom_offer_amount,
|
||||||
|
custom_offer_currency,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!payment_method) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Método de pagamento é obrigatório" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar listing
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se não é o vendedor
|
||||||
|
if (listing.seller_id === auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Você não pode comprar seu próprio anúncio" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se está ativo
|
||||||
|
if (listing.listing_status !== "active") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não está disponível para compra" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se é leilão (leilões não podem ser comprados diretamente, apenas com lances)
|
||||||
|
if (listing.is_auction) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Este item está em leilão. Use a funcionalidade de lance.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar método de pagamento disponível
|
||||||
|
const availableMethods: string[] = [];
|
||||||
|
if (listing.price_pix) availableMethods.push("pix");
|
||||||
|
if (listing.price_tibia_coins) availableMethods.push("tibia_coins");
|
||||||
|
if (listing.price_gold_coins) availableMethods.push("gold_coins");
|
||||||
|
if (listing.trade_item_id) availableMethods.push("trade");
|
||||||
|
|
||||||
|
// Normalizar método de pagamento
|
||||||
|
let normalizedMethod = payment_method;
|
||||||
|
if (payment_method === "tc") normalizedMethod = "tibia_coins";
|
||||||
|
if (payment_method === "gp") normalizedMethod = "gold_coins";
|
||||||
|
|
||||||
|
if (!availableMethods.includes(normalizedMethod)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
|
||||||
|
available_methods: availableMethods,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedOfferValue: number | null = null;
|
||||||
|
let normalizedOfferCurrency: string | null = null;
|
||||||
|
|
||||||
|
if (custom_offer_amount !== undefined && custom_offer_amount !== null) {
|
||||||
|
if (!["pix", "tibia_coins", "gold_coins"].includes(normalizedMethod)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Ofertas personalizadas só são permitidas para métodos monetários (PIX, Tibia Coins ou Gold Coins).",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericOffer = Number(custom_offer_amount);
|
||||||
|
if (Number.isNaN(numericOffer) || numericOffer <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Valor da oferta personalizada deve ser numérico e maior que zero.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedOfferCurrency =
|
||||||
|
custom_offer_currency &&
|
||||||
|
["pix", "tibia_coins", "gold_coins"].includes(custom_offer_currency)
|
||||||
|
? custom_offer_currency
|
||||||
|
: normalizedMethod;
|
||||||
|
|
||||||
|
normalizedOfferValue =
|
||||||
|
normalizedOfferCurrency === "pix"
|
||||||
|
? Number(numericOffer.toFixed(2))
|
||||||
|
: Math.floor(numericOffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já existe uma intenção pendente
|
||||||
|
const existingIntention = await getPendingIntentionByBuyerAndListing(
|
||||||
|
listingId,
|
||||||
|
auth.userId,
|
||||||
|
);
|
||||||
|
if (existingIntention) {
|
||||||
|
// Se já existe e ainda pode trocar o método
|
||||||
|
if (existingIntention.payment_method_change_count < 1) {
|
||||||
|
// Permite atualizar o método via endpoint específico
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Você já possui uma intenção de compra pendente para este anúncio",
|
||||||
|
existing_intention_id: existingIntention.intention_id,
|
||||||
|
can_change_payment_method: true,
|
||||||
|
payment_method_change_count:
|
||||||
|
existingIntention.payment_method_change_count,
|
||||||
|
message:
|
||||||
|
"Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Já atingiu o limite de trocas
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Você já possui uma intenção de compra pendente para este anúncio e atingiu o limite de trocas de método de pagamento",
|
||||||
|
existing_intention_id: existingIntention.intention_id,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar intenção de compra ao invés de mudar status diretamente
|
||||||
|
const intention = await createPurchaseIntention({
|
||||||
|
listing_id: listingId,
|
||||||
|
buyer_id: auth.userId,
|
||||||
|
payment_method: normalizedMethod as any,
|
||||||
|
buyer_message: buyer_message || undefined,
|
||||||
|
custom_offer_amount: normalizedOfferValue ?? undefined,
|
||||||
|
custom_offer_currency: normalizedOfferCurrency as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar notificação para o vendedor
|
||||||
|
await createNotification({
|
||||||
|
user_id: listing.seller_id,
|
||||||
|
notification_type: "purchase",
|
||||||
|
title: "Nova intenção de compra!",
|
||||||
|
message:
|
||||||
|
normalizedOfferValue && normalizedOfferCurrency
|
||||||
|
? `Alguém fez uma oferta de ${normalizedOfferCurrency === "pix" ? `R$ ${normalizedOfferValue.toFixed(2)}` : `${normalizedOfferValue.toLocaleString()} ${normalizedOfferCurrency === "tibia_coins" ? "TC" : "GP"}`} pelo item "${listing.item_name}".`
|
||||||
|
: `Alguém manifestou interesse em comprar seu item "${listing.item_name}" por ${normalizedMethod.toUpperCase()}.`,
|
||||||
|
related_id: listingId,
|
||||||
|
send_whatsapp: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Registrar telemetria
|
||||||
|
await trackEvent({
|
||||||
|
event_type: "purchase_intention_created",
|
||||||
|
ad_id: listingId,
|
||||||
|
user_id: auth.userId,
|
||||||
|
metadata: {
|
||||||
|
payment_method: normalizedMethod,
|
||||||
|
listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null,
|
||||||
|
listing_price_tc: listing.price_tibia_coins || null,
|
||||||
|
listing_price_gp: listing.price_gold_coins
|
||||||
|
? Number(listing.price_gold_coins)
|
||||||
|
: null,
|
||||||
|
custom_offer_amount: normalizedOfferValue,
|
||||||
|
custom_offer_currency: normalizedOfferCurrency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Intenção de compra criada com sucesso", {
|
||||||
|
userId: auth.userId,
|
||||||
|
listingId,
|
||||||
|
intentionId: intention.intention_id,
|
||||||
|
paymentMethod: normalizedMethod,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
intention,
|
||||||
|
message:
|
||||||
|
"Intenção de compra criada com sucesso. O vendedor será notificado.",
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao criar intenção de compra", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao criar intenção de compra" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { optionalAuth, requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
deleteListing,
|
||||||
|
getListingById,
|
||||||
|
updateListing,
|
||||||
|
} from "@/lib/prisma/queries/listings";
|
||||||
|
import { hasPendingIntention } from "@/lib/prisma/queries/purchase-intentions";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { updateListingSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/listings/[id]
|
||||||
|
* Busca anúncio por ID
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const auth = await optionalAuth(request);
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Anúncio não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPendingForUser = auth?.userId
|
||||||
|
? await hasPendingIntention(listingId, auth.userId)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
listing: {
|
||||||
|
...listing,
|
||||||
|
has_pending_intention_for_current_user: hasPendingForUser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar anúncio", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao buscar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/listings/[id]
|
||||||
|
* Atualiza anúncio (apenas dono)
|
||||||
|
*/
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Autenticação obrigatória
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o anúncio existe e pertence ao usuário
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Anúncio não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.seller_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para editar este anúncio",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Validação com Zod
|
||||||
|
const validationResult = updateListingSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
const errors = validationResult.error.issues.map((err) => ({
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Converter null para undefined para compatibilidade com UpdateListingData
|
||||||
|
const updateData: {
|
||||||
|
quantity?: number;
|
||||||
|
price_pix?: number;
|
||||||
|
price_tibia_coins?: number;
|
||||||
|
price_gold_coins?: number;
|
||||||
|
description?: string;
|
||||||
|
listing_status?:
|
||||||
|
| "active"
|
||||||
|
| "sold"
|
||||||
|
| "cancelled"
|
||||||
|
| "in_escrow"
|
||||||
|
| "expired";
|
||||||
|
current_bid_amount?: number;
|
||||||
|
current_bidder_id?: number;
|
||||||
|
} = {
|
||||||
|
...(validatedData.quantity !== undefined && {
|
||||||
|
quantity: validatedData.quantity,
|
||||||
|
}),
|
||||||
|
...(validatedData.price_pix !== null &&
|
||||||
|
validatedData.price_pix !== undefined && {
|
||||||
|
price_pix: validatedData.price_pix,
|
||||||
|
}),
|
||||||
|
...(validatedData.price_tibia_coins !== null &&
|
||||||
|
validatedData.price_tibia_coins !== undefined && {
|
||||||
|
price_tibia_coins: validatedData.price_tibia_coins,
|
||||||
|
}),
|
||||||
|
...(validatedData.price_gold_coins !== null &&
|
||||||
|
validatedData.price_gold_coins !== undefined && {
|
||||||
|
price_gold_coins: validatedData.price_gold_coins,
|
||||||
|
}),
|
||||||
|
...(validatedData.description !== null &&
|
||||||
|
validatedData.description !== undefined && {
|
||||||
|
description: validatedData.description,
|
||||||
|
}),
|
||||||
|
...(validatedData.listing_status !== undefined && {
|
||||||
|
listing_status: validatedData.listing_status,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Atualizar anúncio
|
||||||
|
const updatedListing = await updateListing(listingId, updateData);
|
||||||
|
|
||||||
|
if (!updatedListing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao atualizar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.info("✅ Anúncio atualizado com sucesso", {
|
||||||
|
listingId,
|
||||||
|
userId: auth.userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
listing: updatedListing,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao atualizar anúncio", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao atualizar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/listings/[id]
|
||||||
|
* Cancela anúncio (apenas dono)
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Autenticação obrigatória
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o anúncio existe e pertence ao usuário
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Anúncio não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.seller_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Você não tem permissão para cancelar este anúncio",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelar anúncio (soft delete)
|
||||||
|
const deleted = await deleteListing(listingId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao cancelar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.info("✅ Anúncio cancelado com sucesso", {
|
||||||
|
listingId,
|
||||||
|
userId: auth.userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Anúncio cancelado com sucesso",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao cancelar anúncio", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao cancelar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getMainCharacterByUserId } from "@/lib/prisma/queries/characters";
|
||||||
|
import { getListingById } from "@/lib/prisma/queries/listings";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/listings/[id]/seller
|
||||||
|
* Retorna informações do vendedor (nome do personagem)
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de anúncio inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = await getListingById(listingId);
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar personagem principal do vendedor no servidor do listing
|
||||||
|
const character = await getMainCharacterByUserId(
|
||||||
|
listing.seller_id,
|
||||||
|
listing.server_name,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
seller: {
|
||||||
|
user_id: listing.seller_id,
|
||||||
|
character_name: character?.character_name || null,
|
||||||
|
server_name: listing.server_name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar informações do vendedor", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar informações do vendedor" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { incrementViews } from "@/lib/prisma/queries/listings";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/listings/[id]/views
|
||||||
|
* Incrementa contador de views de um anúncio
|
||||||
|
* Não requer autenticação (público)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const listingId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(listingId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrementar views
|
||||||
|
await incrementViews(listingId);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "View incrementada",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao incrementar views", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao incrementar views",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { getListings } from "@/lib/prisma/queries/listings";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/listings/my
|
||||||
|
* Lista anúncios do usuário autenticado
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Autenticação obrigatória
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
seller_id: auth.userId,
|
||||||
|
status: searchParams.get("status") as
|
||||||
|
| "active"
|
||||||
|
| "sold"
|
||||||
|
| "cancelled"
|
||||||
|
| "in_escrow"
|
||||||
|
| "expired"
|
||||||
|
| undefined,
|
||||||
|
page: searchParams.get("page")
|
||||||
|
? parseInt(searchParams.get("page")!, 10)
|
||||||
|
: 1,
|
||||||
|
pageSize: searchParams.get("pageSize")
|
||||||
|
? parseInt(searchParams.get("pageSize")!, 10)
|
||||||
|
: 20,
|
||||||
|
sortBy: (searchParams.get("sortBy") || "created_at") as
|
||||||
|
| "created_at"
|
||||||
|
| "price_pix"
|
||||||
|
| "price_tibia_coins"
|
||||||
|
| "price_gold_coins"
|
||||||
|
| "views_count",
|
||||||
|
sortOrder: (searchParams.get("sortOrder") || "DESC") as "ASC" | "DESC",
|
||||||
|
};
|
||||||
|
|
||||||
|
const listingsData = await getListings(filters);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
listings: listingsData.listings,
|
||||||
|
pagination: {
|
||||||
|
page: filters.page,
|
||||||
|
pageSize: filters.pageSize,
|
||||||
|
total: listingsData.total,
|
||||||
|
totalPages: Math.ceil(listingsData.total / filters.pageSize),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar anúncios do usuário", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao buscar anúncios",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { optionalAuth, requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
createListing,
|
||||||
|
getActiveListings,
|
||||||
|
getListings,
|
||||||
|
} from "@/lib/prisma/queries/listings";
|
||||||
|
import { getPendingIntentionListingIdsForBuyer } from "@/lib/prisma/queries/purchase-intentions";
|
||||||
|
import { isServerActive } from "@/lib/prisma/queries/servers";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
createRateLimitResponse,
|
||||||
|
getClientIP,
|
||||||
|
} from "@/lib/rate-limiter";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { createListingSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/listings
|
||||||
|
* Lista anúncios com filtros opcionais
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await optionalAuth(request);
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
status: searchParams.get("status") as
|
||||||
|
| "active"
|
||||||
|
| "sold"
|
||||||
|
| "cancelled"
|
||||||
|
| "in_escrow"
|
||||||
|
| "expired"
|
||||||
|
| undefined,
|
||||||
|
server_name: searchParams.get("server_name") || undefined,
|
||||||
|
item_id: searchParams.get("item_id")
|
||||||
|
? parseInt(searchParams.get("item_id")!, 10)
|
||||||
|
: undefined,
|
||||||
|
seller_id: searchParams.get("seller_id")
|
||||||
|
? parseInt(searchParams.get("seller_id")!, 10)
|
||||||
|
: undefined,
|
||||||
|
is_auction:
|
||||||
|
searchParams.get("is_auction") === "true"
|
||||||
|
? true
|
||||||
|
: searchParams.get("is_auction") === "false"
|
||||||
|
? false
|
||||||
|
: undefined,
|
||||||
|
search: searchParams.get("search") || undefined,
|
||||||
|
page: searchParams.get("page")
|
||||||
|
? parseInt(searchParams.get("page")!, 10)
|
||||||
|
: 1,
|
||||||
|
pageSize: searchParams.get("pageSize")
|
||||||
|
? parseInt(searchParams.get("pageSize")!, 10)
|
||||||
|
: 20,
|
||||||
|
sortBy: (searchParams.get("sortBy") || "created_at") as
|
||||||
|
| "created_at"
|
||||||
|
| "price_pix"
|
||||||
|
| "price_tibia_coins"
|
||||||
|
| "price_gold_coins"
|
||||||
|
| "views_count",
|
||||||
|
sortOrder: (searchParams.get("sortOrder") || "DESC") as "ASC" | "DESC",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Se não especificar status, usar apenas ativos por padrão
|
||||||
|
const listingsData = filters.status
|
||||||
|
? await getListings(filters)
|
||||||
|
: await getActiveListings(filters);
|
||||||
|
|
||||||
|
let listingsResponse = listingsData.listings;
|
||||||
|
|
||||||
|
if (auth && listingsResponse.length > 0) {
|
||||||
|
const listingIds = listingsResponse.map((listing) => listing.listing_id);
|
||||||
|
const pendingListingIds = await getPendingIntentionListingIdsForBuyer(
|
||||||
|
auth.userId,
|
||||||
|
listingIds,
|
||||||
|
);
|
||||||
|
const pendingSet = new Set(pendingListingIds);
|
||||||
|
|
||||||
|
listingsResponse = listingsResponse.map((listing) => ({
|
||||||
|
...listing,
|
||||||
|
has_pending_intention_for_current_user: pendingSet.has(
|
||||||
|
listing.listing_id,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
listings: listingsResponse,
|
||||||
|
pagination: {
|
||||||
|
page: filters.page,
|
||||||
|
pageSize: filters.pageSize,
|
||||||
|
total: listingsData.total,
|
||||||
|
totalPages: Math.ceil(listingsData.total / filters.pageSize),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar anúncios", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao buscar anúncios",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/listings
|
||||||
|
* Cria um novo anúncio (requer autenticação)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const rateLimit = checkRateLimit(ip, "/api/listings");
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return createRateLimitResponse(rateLimit.resetAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autenticação obrigatória
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
secureLogger.info("📥 POST /api/listings - Recebido", {
|
||||||
|
userId: auth.userId,
|
||||||
|
ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validação com Zod
|
||||||
|
const validationResult = createListingSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
const errors = validationResult.error.issues.map((err) => ({
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Validar se o servidor existe e está ativo
|
||||||
|
const serverActive = await isServerActive(validatedData.server_name);
|
||||||
|
if (!serverActive) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `Servidor "${validatedData.server_name}" não existe ou não está ativo`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar anúncio
|
||||||
|
// O schema garante que pelo menos item_id ou item_id_unified está presente
|
||||||
|
// Mas a interface CreateListingData exige item_id, então usamos item_id_unified como fallback
|
||||||
|
// O refine do schema garante que pelo menos um está presente, então o fallback nunca será usado
|
||||||
|
const itemId = validatedData.item_id ?? validatedData.item_id_unified!;
|
||||||
|
|
||||||
|
const listing = await createListing({
|
||||||
|
seller_id: auth.userId,
|
||||||
|
item_id: itemId,
|
||||||
|
item_name: validatedData.item_name,
|
||||||
|
quantity: validatedData.quantity,
|
||||||
|
server_name: validatedData.server_name,
|
||||||
|
price_pix: validatedData.price_pix ?? undefined,
|
||||||
|
price_tibia_coins: validatedData.price_tibia_coins ?? undefined,
|
||||||
|
price_gold_coins: validatedData.price_gold_coins ?? undefined,
|
||||||
|
is_auction: validatedData.is_auction,
|
||||||
|
auction_ends_at: validatedData.auction_ends_at ?? undefined,
|
||||||
|
description: validatedData.description ?? undefined,
|
||||||
|
trade_item_id: validatedData.trade_item_id ?? undefined,
|
||||||
|
trade_item_name: validatedData.trade_item_name ?? undefined,
|
||||||
|
trade_item_quantity: validatedData.trade_item_quantity ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Anúncio criado com sucesso", {
|
||||||
|
listingId: listing.listing_id,
|
||||||
|
userId: auth.userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
listing,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao criar anúncio", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao criar anúncio",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { markAsRead } from "@/lib/prisma/queries/notifications";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/notifications/[id]/read
|
||||||
|
* Marca uma notificação específica como lida
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const notificationId = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(notificationId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de notificação inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await markAsRead(notificationId, auth.userId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Notificação não encontrada ou já está lida" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Notificação marcada como lida",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao marcar notificação como lida", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao marcar notificação como lida" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
getUnreadCount,
|
||||||
|
getUserNotifications,
|
||||||
|
markAllAsRead,
|
||||||
|
} from "@/lib/prisma/queries/notifications";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/notifications
|
||||||
|
* Lista notificações do usuário autenticado
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const isRead = searchParams.get("is_read");
|
||||||
|
const limit = searchParams.get("limit")
|
||||||
|
? parseInt(searchParams.get("limit")!, 10)
|
||||||
|
: 50;
|
||||||
|
|
||||||
|
const notifications = await getUserNotifications(auth.userId, {
|
||||||
|
is_read:
|
||||||
|
isRead === "true" ? true : isRead === "false" ? false : undefined,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCount = await getUnreadCount(auth.userId);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
notifications,
|
||||||
|
unread_count: unreadCount,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar notificações", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar notificações" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/notifications/read-all
|
||||||
|
* Marca todas as notificações como lidas
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const action = searchParams.get("action");
|
||||||
|
|
||||||
|
if (action === "read-all") {
|
||||||
|
const count = await markAllAsRead(auth.userId);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: `${count} notificações marcadas como lidas`,
|
||||||
|
count,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Ação inválida" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao marcar notificações como lidas", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao marcar notificações como lidas" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { getUnreadCount } from "@/lib/prisma/queries/notifications";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/notifications/unread-count
|
||||||
|
* Retorna a contagem de notificações não lidas do usuário
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
|
||||||
|
const count = await getUnreadCount(authResult.userId);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
count,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (_error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar contagem de notificações" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* PATCH /api/purchase-intentions/[intentionId]/payment-method
|
||||||
|
* Permite ao comprador trocar o método de pagamento (máximo 1 vez)
|
||||||
|
*/
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { createSystemMessageInChat } from "@/lib/chat/system-messages";
|
||||||
|
import { getListingById } from "@/lib/prisma/queries/listings";
|
||||||
|
import {
|
||||||
|
getPurchaseIntentionById,
|
||||||
|
updatePurchaseIntentionPaymentMethod,
|
||||||
|
} from "@/lib/prisma/queries/purchase-intentions";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
function getPaymentMethodLabel(method: string): string {
|
||||||
|
switch (method) {
|
||||||
|
case "pix":
|
||||||
|
return "PIX";
|
||||||
|
case "tibia_coins":
|
||||||
|
return "Tibia Coins";
|
||||||
|
case "gold_coins":
|
||||||
|
return "Gold Coins";
|
||||||
|
case "trade":
|
||||||
|
return "Troca";
|
||||||
|
default:
|
||||||
|
return method.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ intentionId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { intentionId: intentionIdParam } = await params;
|
||||||
|
const intentionId = parseInt(intentionIdParam, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(intentionId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de intenção inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { payment_method } = body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!payment_method ||
|
||||||
|
!["pix", "tibia_coins", "gold_coins", "trade"].includes(payment_method)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Método de pagamento inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar intenção
|
||||||
|
const intention = await getPurchaseIntentionById(intentionId);
|
||||||
|
if (!intention) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Intenção não encontrada" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o usuário é o comprador
|
||||||
|
if (intention.buyer_id !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Apenas o comprador pode trocar o método de pagamento",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se a intenção está pendente
|
||||||
|
if (intention.status !== "pending") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Apenas intenções pendentes podem ter o método de pagamento alterado",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já atingiu o limite de trocas
|
||||||
|
if (intention.payment_method_change_count >= 1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Você já atingiu o limite de trocas de método de pagamento (1 vez)",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o novo método é diferente
|
||||||
|
if (intention.payment_method === payment_method) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "O novo método de pagamento deve ser diferente do atual",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar listing para validar método disponível
|
||||||
|
const listing = await getListingById(intention.listing_id);
|
||||||
|
if (!listing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Anúncio não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar método de pagamento disponível
|
||||||
|
const availableMethods: string[] = [];
|
||||||
|
if (listing.price_pix) availableMethods.push("pix");
|
||||||
|
if (listing.price_tibia_coins) availableMethods.push("tibia_coins");
|
||||||
|
if (listing.price_gold_coins) availableMethods.push("gold_coins");
|
||||||
|
if (listing.trade_item_id) availableMethods.push("trade");
|
||||||
|
|
||||||
|
if (!availableMethods.includes(payment_method)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
|
||||||
|
available_methods: availableMethods,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar método de pagamento
|
||||||
|
const oldPaymentMethod = intention.payment_method;
|
||||||
|
const updatedIntention = await updatePurchaseIntentionPaymentMethod(
|
||||||
|
intentionId,
|
||||||
|
payment_method as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Criar mensagem do sistema no chat
|
||||||
|
await createSystemMessageInChat({
|
||||||
|
listingId: intention.listing_id,
|
||||||
|
buyerId: intention.buyer_id,
|
||||||
|
sellerId: listing.seller_id,
|
||||||
|
message: `O comprador alterou o método de pagamento de ${getPaymentMethodLabel(oldPaymentMethod)} para ${getPaymentMethodLabel(payment_method)}`,
|
||||||
|
metadata: {
|
||||||
|
action: "payment_method_changed",
|
||||||
|
old_payment_method: oldPaymentMethod,
|
||||||
|
new_payment_method: payment_method,
|
||||||
|
intention_id: intentionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Método de pagamento atualizado", {
|
||||||
|
userId: auth.userId,
|
||||||
|
intentionId,
|
||||||
|
oldPaymentMethod,
|
||||||
|
newPaymentMethod: payment_method,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
intention: updatedIntention,
|
||||||
|
message:
|
||||||
|
"Método de pagamento atualizado com sucesso. O vendedor foi notificado.",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao atualizar método de pagamento", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Erro ao atualizar método de pagamento",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { prisma } from "@/lib/prisma/app";
|
||||||
|
import { getPurchaseIntentionsByBuyer } from "@/lib/prisma/queries/purchase-intentions";
|
||||||
|
import { trackEvent } from "@/lib/prisma/queries/telemetry";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/purchase-intentions/my
|
||||||
|
* Lista todas as intenções de compra do usuário autenticado
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
if (authResult instanceof NextResponse) return authResult;
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
// Buscar parâmetros de filtro
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get("status") as
|
||||||
|
| "pending"
|
||||||
|
| "accepted"
|
||||||
|
| "rejected"
|
||||||
|
| "expired"
|
||||||
|
| "completed"
|
||||||
|
| null;
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "100", 10);
|
||||||
|
|
||||||
|
// Buscar intenções do comprador
|
||||||
|
const intentions = await getPurchaseIntentionsByBuyer(auth.userId, {
|
||||||
|
status: status || undefined,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buscar todos os listings de uma vez (otimização)
|
||||||
|
const listingIds = [...new Set(intentions.map((i) => i.listing_id))];
|
||||||
|
const listings = await prisma.listing.findMany({
|
||||||
|
where: { listing_id: { in: listingIds } },
|
||||||
|
select: {
|
||||||
|
listing_id: true,
|
||||||
|
item_id: true,
|
||||||
|
item_id_unified: true,
|
||||||
|
item_name: true,
|
||||||
|
quantity: true,
|
||||||
|
server_name: true,
|
||||||
|
seller_id: true,
|
||||||
|
price_pix: true,
|
||||||
|
price_tibia_coins: true,
|
||||||
|
price_gold_coins: true,
|
||||||
|
listing_status: true,
|
||||||
|
created_at: true,
|
||||||
|
description: true,
|
||||||
|
trade_item_id: true,
|
||||||
|
trade_item_name: true,
|
||||||
|
trade_item_quantity: true,
|
||||||
|
views_count: true,
|
||||||
|
favorites_count: true,
|
||||||
|
is_featured: true,
|
||||||
|
item: {
|
||||||
|
select: {
|
||||||
|
image_url: true,
|
||||||
|
image_local_path: true,
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seller: {
|
||||||
|
select: {
|
||||||
|
user_id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar mapa de listings para acesso rápido
|
||||||
|
const listingsMap = new Map(listings.map((l) => [l.listing_id, l]));
|
||||||
|
|
||||||
|
// Buscar character names dos vendedores em batch
|
||||||
|
const sellerServerPairs = [
|
||||||
|
...new Set(listings.map((l) => `${l.seller_id}:${l.server_name}`)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const characterMap = new Map<string, string | null>();
|
||||||
|
await Promise.all(
|
||||||
|
sellerServerPairs.map(async (pair) => {
|
||||||
|
const [sellerId, serverName] = pair.split(":");
|
||||||
|
const character = await prisma.userCharacter.findFirst({
|
||||||
|
where: {
|
||||||
|
user_id: parseInt(sellerId, 10),
|
||||||
|
server_name: serverName,
|
||||||
|
is_main: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
character_name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
characterMap.set(pair, character?.character_name || null);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serializar datas para string
|
||||||
|
const serializeDate = (date: Date | null): string | null => {
|
||||||
|
return date ? new Date(date).toISOString() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expandir dados do listing para cada intenção
|
||||||
|
const intentionsWithListingDetails = intentions.map((intention) => {
|
||||||
|
const listing = listingsMap.get(intention.listing_id);
|
||||||
|
const characterKey = listing
|
||||||
|
? `${listing.seller_id}:${listing.server_name}`
|
||||||
|
: null;
|
||||||
|
const sellerCharacterName = characterKey
|
||||||
|
? characterMap.get(characterKey) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const chatId =
|
||||||
|
listing?.seller_id != null
|
||||||
|
? `${intention.listing_id}_${intention.buyer_id}_${listing.seller_id}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...intention,
|
||||||
|
created_at:
|
||||||
|
serializeDate(intention.created_at as Date) || intention.created_at,
|
||||||
|
updated_at:
|
||||||
|
serializeDate(intention.updated_at as Date) || intention.updated_at,
|
||||||
|
expires_at: serializeDate(intention.expires_at as Date | null),
|
||||||
|
accepted_at: serializeDate(intention.accepted_at as Date | null),
|
||||||
|
rejected_at: serializeDate(intention.rejected_at as Date | null),
|
||||||
|
chat_id: chatId,
|
||||||
|
listing: listing
|
||||||
|
? {
|
||||||
|
listing_id: listing.listing_id,
|
||||||
|
item_id: listing.item_id,
|
||||||
|
item_id_unified: listing.item_id_unified,
|
||||||
|
item_name: listing.item_name,
|
||||||
|
quantity: listing.quantity,
|
||||||
|
server_name: listing.server_name,
|
||||||
|
seller_id: listing.seller_id,
|
||||||
|
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
|
||||||
|
price_tibia_coins:
|
||||||
|
listing.price_tibia_coins !== null &&
|
||||||
|
listing.price_tibia_coins !== undefined
|
||||||
|
? Number(listing.price_tibia_coins)
|
||||||
|
: null,
|
||||||
|
price_gold_coins: listing.price_gold_coins
|
||||||
|
? Number(listing.price_gold_coins)
|
||||||
|
: null,
|
||||||
|
listing_status: listing.listing_status,
|
||||||
|
created_at:
|
||||||
|
listing.created_at instanceof Date
|
||||||
|
? listing.created_at.toISOString()
|
||||||
|
: listing.created_at,
|
||||||
|
description: listing.description,
|
||||||
|
trade_item_id: listing.trade_item_id,
|
||||||
|
trade_item_name: listing.trade_item_name,
|
||||||
|
trade_item_quantity: listing.trade_item_quantity,
|
||||||
|
views_count: listing.views_count ?? 0,
|
||||||
|
favorites_count: listing.favorites_count ?? 0,
|
||||||
|
is_featured: listing.is_featured ?? false,
|
||||||
|
image_url: listing.item?.image_url || null,
|
||||||
|
image_local_path: listing.item?.image_local_path || null,
|
||||||
|
item_category: listing.item?.category || null,
|
||||||
|
seller_character_name: sellerCharacterName,
|
||||||
|
seller_username: listing.seller?.username || null,
|
||||||
|
seller_email: listing.seller?.email || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Registrar telemetria
|
||||||
|
await trackEvent({
|
||||||
|
event_type: "purchase_intention_viewed",
|
||||||
|
user_id: auth.userId,
|
||||||
|
metadata: {
|
||||||
|
intentions_count: intentionsWithListingDetails.length,
|
||||||
|
filter_status: status || "all",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
intentions: intentionsWithListingDetails,
|
||||||
|
count: intentionsWithListingDetails.length,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar intenções de compra do usuário", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Erro ao buscar intenções de compra" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getActiveServers } from "@/lib/prisma/queries/servers";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/servers
|
||||||
|
* Lista todos os servidores ativos disponíveis
|
||||||
|
*/
|
||||||
|
export async function GET(_request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const servers = await getActiveServers();
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
servers: servers.map((server) => ({
|
||||||
|
serverId: server.server_id,
|
||||||
|
serverName: server.server_name,
|
||||||
|
serverType: server.server_type,
|
||||||
|
location: server.location,
|
||||||
|
isActive: server.is_active,
|
||||||
|
stats: {
|
||||||
|
totalListings: server.total_listings,
|
||||||
|
totalUsers: server.total_users,
|
||||||
|
totalSubscribers: server.total_subscribers,
|
||||||
|
totalTransactions: server.total_transactions,
|
||||||
|
totalVolume: Number(server.total_volume),
|
||||||
|
averageListingPrice: Number(server.average_listing_price),
|
||||||
|
lastActivityAt: server.last_activity_at,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar servidores", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao buscar servidores",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* API Route para Socket.io
|
||||||
|
* Next.js 15 não suporta servidor HTTP customizado diretamente
|
||||||
|
* Esta rota será usada para health check
|
||||||
|
* O Socket.io será inicializado via server.ts customizado
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ok",
|
||||||
|
message: "Socket.io está ativo",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
countUsersInSubscriptionTier,
|
||||||
|
deleteSubscriptionTier,
|
||||||
|
getSubscriptionTierById,
|
||||||
|
updateSubscriptionTier,
|
||||||
|
} from "@/lib/prisma/queries/subscription-tiers";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { updateSubscriptionTierSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const tierId = Number.parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(tierId) || tierId <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de tier inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = await getSubscriptionTierById(tierId, true);
|
||||||
|
|
||||||
|
if (!tier) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Plano não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
tier,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao consultar subscription tier", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao consultar plano de assinatura",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const tierId = Number.parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(tierId) || tierId <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de tier inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = updateSubscriptionTierSchema.parse(body);
|
||||||
|
|
||||||
|
const updatedTier = await updateSubscriptionTier(tierId, {
|
||||||
|
name: parsed.name,
|
||||||
|
slug: parsed.slug,
|
||||||
|
description: parsed.description ?? undefined,
|
||||||
|
level: parsed.level,
|
||||||
|
price_monthly: parsed.priceMonthly ?? undefined,
|
||||||
|
price_yearly: parsed.priceYearly ?? undefined,
|
||||||
|
is_active: parsed.isActive,
|
||||||
|
features: parsed.features?.map((feature, index) => ({
|
||||||
|
feature_key: feature.featureKey,
|
||||||
|
label: feature.label,
|
||||||
|
description: feature.description ?? null,
|
||||||
|
sort_order: feature.sortOrder ?? index,
|
||||||
|
_delete: feature._delete ?? false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
tier: updatedTier,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && "issues" in error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Dados inválidos",
|
||||||
|
details: (error as any).issues,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao atualizar subscription tier", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao atualizar plano de assinatura",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const tierId = Number.parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(tierId) || tierId <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "ID de tier inválido" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = await getSubscriptionTierById(tierId, false);
|
||||||
|
|
||||||
|
if (!tier) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Plano não encontrado" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tier.slug === "free") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "O plano padrão 'free' não pode ser removido.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCount = await countUsersInSubscriptionTier(tierId);
|
||||||
|
|
||||||
|
if (userCount > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Plano não pode ser removido enquanto houver usuários associados.",
|
||||||
|
users: userCount,
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteSubscriptionTier(tierId);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao remover subscription tier", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao remover plano de assinatura",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
assignUserToSubscriptionTier,
|
||||||
|
getDefaultSubscriptionTier,
|
||||||
|
getSubscriptionTierById,
|
||||||
|
getSubscriptionTierBySlug,
|
||||||
|
} from "@/lib/prisma/queries/subscription-tiers";
|
||||||
|
import { getUserById } from "@/lib/prisma/queries/users";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { assignSubscriptionTierSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = assignSubscriptionTierSchema.parse(body);
|
||||||
|
|
||||||
|
const user = await getUserById(parsed.userId);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Usuário não encontrado.",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetTierId: number | null = null;
|
||||||
|
let targetTier: {
|
||||||
|
subscription_tier_id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
level: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
if (parsed.tierId !== undefined) {
|
||||||
|
if (parsed.tierId === null) {
|
||||||
|
const defaultTier = await getDefaultSubscriptionTier();
|
||||||
|
if (!defaultTier) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Plano padrão não encontrado.",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targetTier = defaultTier;
|
||||||
|
} else {
|
||||||
|
const tierById = await getSubscriptionTierById(parsed.tierId, false);
|
||||||
|
if (!tierById) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Plano informado não existe.",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targetTier = tierById;
|
||||||
|
}
|
||||||
|
} else if (parsed.tierSlug) {
|
||||||
|
const tierBySlug = await getSubscriptionTierBySlug(
|
||||||
|
parsed.tierSlug,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (!tierBySlug) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Plano informado não existe.",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targetTier = tierBySlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetTier) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Plano de assinatura não identificado.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
targetTierId = targetTier.subscription_tier_id;
|
||||||
|
|
||||||
|
await assignUserToSubscriptionTier(parsed.userId, targetTierId);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
userId: parsed.userId,
|
||||||
|
tier: {
|
||||||
|
id: targetTier.subscription_tier_id,
|
||||||
|
name: targetTier.name,
|
||||||
|
slug: targetTier.slug,
|
||||||
|
level: targetTier.level,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && "issues" in error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Dados inválidos",
|
||||||
|
details: (error as any).issues,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao atribuir subscription tier", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao atribuir plano de assinatura ao usuário",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
createSubscriptionTier,
|
||||||
|
getAllSubscriptionTiers,
|
||||||
|
} from "@/lib/prisma/queries/subscription-tiers";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { createSubscriptionTierSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tiers = await getAllSubscriptionTiers(true);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
tiers,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao listar subscription tiers", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao listar planos de assinatura",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdminUser(request);
|
||||||
|
if (auth instanceof NextResponse) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = createSubscriptionTierSchema.parse(body);
|
||||||
|
|
||||||
|
const tier = await createSubscriptionTier({
|
||||||
|
name: parsed.name,
|
||||||
|
slug: parsed.slug,
|
||||||
|
description: parsed.description ?? null,
|
||||||
|
level: parsed.level ?? 0,
|
||||||
|
price_monthly: parsed.priceMonthly ?? null,
|
||||||
|
price_yearly: parsed.priceYearly ?? null,
|
||||||
|
is_active: parsed.isActive ?? true,
|
||||||
|
features: parsed.features?.map((feature, index) => ({
|
||||||
|
feature_key: feature.featureKey,
|
||||||
|
label: feature.label,
|
||||||
|
description: feature.description ?? null,
|
||||||
|
sort_order: feature.sortOrder ?? index,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
tier,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NextResponse) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && "issues" in error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Dados inválidos",
|
||||||
|
details: (error as any).issues,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao criar subscription tier", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao criar plano de assinatura",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { optionalAuth } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
getAdAnalytics,
|
||||||
|
getMarketplaceStats,
|
||||||
|
trackEvent,
|
||||||
|
trackEventsBatch,
|
||||||
|
} from "@/lib/prisma/queries/telemetry";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
createRateLimitResponse,
|
||||||
|
getClientIP,
|
||||||
|
} from "@/lib/rate-limiter";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import {
|
||||||
|
telemetryBatchSchema,
|
||||||
|
telemetryEventSchema,
|
||||||
|
} from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/telemetry/event
|
||||||
|
* Registra um único evento de telemetria
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const rateLimit = checkRateLimit(ip, "/api/telemetry/event");
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return createRateLimitResponse(rateLimit.resetAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autenticação opcional (usuários anônimos podem gerar eventos)
|
||||||
|
const authResult = await optionalAuth(request);
|
||||||
|
const userId =
|
||||||
|
authResult instanceof NextResponse ? undefined : authResult?.userId;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Verificar se é batch ou evento único
|
||||||
|
const isBatch =
|
||||||
|
body.events && Array.isArray(body.events) && body.events.length > 0;
|
||||||
|
|
||||||
|
if (isBatch) {
|
||||||
|
// Batch de eventos
|
||||||
|
// Garantir que todos os eventos tenham metadata
|
||||||
|
const normalizedBody = {
|
||||||
|
...body,
|
||||||
|
events: body.events.map((event: any) => ({
|
||||||
|
...event,
|
||||||
|
metadata: event.metadata || {},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationResult = telemetryBatchSchema.safeParse(normalizedBody);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Dados de entrada inválidos",
|
||||||
|
details: validationResult.error.issues,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { events, session_id: batchSessionId } = validationResult.data;
|
||||||
|
|
||||||
|
// Adicionar user_id e session_id aos eventos
|
||||||
|
const eventsWithAuth = events.map((event) => ({
|
||||||
|
...event,
|
||||||
|
user_id: userId,
|
||||||
|
session_id: batchSessionId || event.session_id || undefined,
|
||||||
|
ad_id: event.ad_id ?? undefined, // Converter null para undefined
|
||||||
|
metadata: event.metadata || {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const trackedEvents = await trackEventsBatch(eventsWithAuth);
|
||||||
|
|
||||||
|
secureLogger.debug("📊 Batch de eventos registrado", {
|
||||||
|
count: trackedEvents.length,
|
||||||
|
userId,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
events: trackedEvents,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Evento único
|
||||||
|
// Garantir que o evento tenha metadata
|
||||||
|
const normalizedBody = {
|
||||||
|
...body,
|
||||||
|
metadata: body.metadata || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationResult = telemetryEventSchema.safeParse(normalizedBody);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Dados de entrada inválidos",
|
||||||
|
details: validationResult.error.issues,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = validationResult.data;
|
||||||
|
|
||||||
|
// Adicionar user_id e session_id
|
||||||
|
const trackedEvent = await trackEvent({
|
||||||
|
...eventData,
|
||||||
|
user_id: userId,
|
||||||
|
session_id: body.session_id || undefined,
|
||||||
|
ad_id: eventData.ad_id ?? undefined, // Converter null para undefined
|
||||||
|
metadata: eventData.metadata || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.debug("📊 Evento registrado", {
|
||||||
|
eventType: eventData.event_type,
|
||||||
|
adId: eventData.ad_id,
|
||||||
|
userId,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
event: trackedEvent,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao registrar evento de telemetria", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao registrar evento",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/telemetry/analytics/:adId
|
||||||
|
* Busca analytics de um anúncio específico
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const adIdParam = searchParams.get("adId");
|
||||||
|
const dashboard = searchParams.get("dashboard");
|
||||||
|
|
||||||
|
// Dashboard agregado (admin)
|
||||||
|
if (dashboard === "true") {
|
||||||
|
const startDateParam = searchParams.get("startDate");
|
||||||
|
const endDateParam = searchParams.get("endDate");
|
||||||
|
|
||||||
|
const startDate = startDateParam ? new Date(startDateParam) : undefined;
|
||||||
|
const endDate = endDateParam ? new Date(endDateParam) : undefined;
|
||||||
|
|
||||||
|
const stats = await getMarketplaceStats(startDate, endDate);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
stats,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics de um anúncio específico
|
||||||
|
if (adIdParam) {
|
||||||
|
const adId = parseInt(adIdParam, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(adId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID do anúncio inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const analytics = await getAdAnalytics(adId);
|
||||||
|
|
||||||
|
if (!analytics) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Analytics não encontrado para este anúncio",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
analytics,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Parâmetro 'adId' ou 'dashboard' é obrigatório",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar analytics", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao buscar analytics",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getUserById } from "@/lib/prisma/queries/users";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const userId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(userId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de usuário inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Usuário não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retornar apenas dados públicos (sem informações sensíveis)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
userId: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
accountLevel: user.account_level,
|
||||||
|
hasDonated: user.has_donated,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
secureLogger.error("❌ Erro ao buscar perfil público", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
deleteCharacter,
|
||||||
|
updateCharacter,
|
||||||
|
} from "@/lib/prisma/queries/characters";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { characterSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Middleware de autenticação
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const characterId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(characterId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de personagem inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Validação com Zod
|
||||||
|
const validationResult = characterSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
const errors = validationResult.error.issues.map((err) => ({
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Atualizar personagem (verifica ownership internamente)
|
||||||
|
const updatedCharacter = await updateCharacter(characterId, auth.userId, {
|
||||||
|
character_name: validatedData.characterName,
|
||||||
|
server_name: validatedData.serverName,
|
||||||
|
is_main: validatedData.isMain,
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Personagem atualizado com sucesso", {
|
||||||
|
userId: auth.userId,
|
||||||
|
characterId: updatedCharacter.character_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
character: {
|
||||||
|
characterId: updatedCharacter.character_id,
|
||||||
|
characterName: updatedCharacter.character_name,
|
||||||
|
serverName: updatedCharacter.server_name,
|
||||||
|
isMain: updatedCharacter.is_main,
|
||||||
|
verified: updatedCharacter.verified,
|
||||||
|
createdAt: updatedCharacter.created_at,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Se for NextResponse (erro de autenticação), re-lançar
|
||||||
|
if (error instanceof NextResponse) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é erro de permissão
|
||||||
|
if (error instanceof Error && error.message.includes("não tem permissão")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao atualizar personagem", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Middleware de autenticação
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const characterId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(characterId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "ID de personagem inválido",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover personagem (verifica ownership internamente)
|
||||||
|
await deleteCharacter(characterId, auth.userId);
|
||||||
|
|
||||||
|
secureLogger.info("✅ Personagem removido com sucesso", {
|
||||||
|
userId: auth.userId,
|
||||||
|
characterId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Se for NextResponse (erro de autenticação), re-lançar
|
||||||
|
if (error instanceof NextResponse) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é erro de permissão
|
||||||
|
if (error instanceof Error && error.message.includes("não tem permissão")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao remover personagem", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import {
|
||||||
|
createCharacter,
|
||||||
|
getUserCharacters,
|
||||||
|
} from "@/lib/prisma/queries/characters";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { characterSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Middleware de autenticação
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
// Listar personagens do usuário
|
||||||
|
const characters = await getUserCharacters(auth.userId);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
characters: characters.map((char) => ({
|
||||||
|
characterId: char.character_id,
|
||||||
|
characterName: char.character_name,
|
||||||
|
serverName: char.server_name,
|
||||||
|
isMain: char.is_main,
|
||||||
|
verified: char.verified,
|
||||||
|
createdAt: char.created_at,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Se for NextResponse (erro de autenticação), re-lançar
|
||||||
|
if (error instanceof NextResponse) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao listar personagens", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Middleware de autenticação
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Validação com Zod
|
||||||
|
const validationResult = characterSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
const errors = validationResult.error.issues.map((err) => ({
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Criar personagem
|
||||||
|
const newCharacter = await createCharacter(auth.userId, {
|
||||||
|
character_name: validatedData.characterName,
|
||||||
|
server_name: validatedData.serverName,
|
||||||
|
is_main: validatedData.isMain,
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Personagem criado com sucesso", {
|
||||||
|
userId: auth.userId,
|
||||||
|
characterId: newCharacter.character_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
character: {
|
||||||
|
characterId: newCharacter.character_id,
|
||||||
|
characterName: newCharacter.character_name,
|
||||||
|
serverName: newCharacter.server_name,
|
||||||
|
isMain: newCharacter.is_main,
|
||||||
|
verified: newCharacter.verified,
|
||||||
|
createdAt: newCharacter.created_at,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Se for NextResponse (erro de autenticação), re-lançar
|
||||||
|
if (error instanceof NextResponse) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é erro de constraint (personagem duplicado)
|
||||||
|
if (error instanceof Error && error.message.includes("UNIQUE")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Este personagem já está cadastrado neste servidor",
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao criar personagem", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth/middleware";
|
||||||
|
import { getUserCharacters } from "@/lib/prisma/queries/characters";
|
||||||
|
import { getUserById, updateUser } from "@/lib/prisma/queries/users";
|
||||||
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
import { updateUserSchema } from "@/lib/validation-schemas";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Middleware de autenticação
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
// Buscar dados do usuário
|
||||||
|
const user = await getUserById(auth.userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Usuário não encontrado",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar personagens do usuário
|
||||||
|
const characters = await getUserCharacters(auth.userId);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
userId: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
phone: user.phone,
|
||||||
|
cpf: user.cpf,
|
||||||
|
accountLevel: user.account_level,
|
||||||
|
hasDonated: user.has_donated,
|
||||||
|
whatsappNotifications: user.whatsapp_notifications,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
updatedAt: user.updated_at,
|
||||||
|
lastLogin: user.last_login,
|
||||||
|
isActive: user.is_active,
|
||||||
|
isVerified: user.is_verified,
|
||||||
|
},
|
||||||
|
characters: characters.map((char) => ({
|
||||||
|
characterId: char.character_id,
|
||||||
|
characterName: char.character_name,
|
||||||
|
serverName: char.server_name,
|
||||||
|
isMain: char.is_main,
|
||||||
|
verified: char.verified,
|
||||||
|
createdAt: char.created_at,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Se for NextResponse (erro de autenticação), re-lançar
|
||||||
|
if (error instanceof NextResponse) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao buscar perfil do usuário", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Middleware de autenticação
|
||||||
|
const authResult = await requireAuth(request);
|
||||||
|
|
||||||
|
if (authResult instanceof NextResponse) {
|
||||||
|
return authResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = authResult;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Validação com Zod
|
||||||
|
const validationResult = updateUserSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
const errors = validationResult.error.issues.map((err) => ({
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Atualizar usuário
|
||||||
|
const updatedUser = await updateUser(auth.userId, {
|
||||||
|
first_name: validatedData.firstName,
|
||||||
|
last_name: validatedData.lastName,
|
||||||
|
phone: validatedData.phone,
|
||||||
|
whatsapp_notifications: validatedData.whatsappNotifications,
|
||||||
|
});
|
||||||
|
|
||||||
|
secureLogger.info("✅ Perfil atualizado com sucesso", {
|
||||||
|
userId: auth.userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
userId: updatedUser.user_id,
|
||||||
|
username: updatedUser.username,
|
||||||
|
email: updatedUser.email,
|
||||||
|
firstName: updatedUser.first_name,
|
||||||
|
lastName: updatedUser.last_name,
|
||||||
|
phone: updatedUser.phone,
|
||||||
|
cpf: updatedUser.cpf,
|
||||||
|
accountLevel: updatedUser.account_level,
|
||||||
|
hasDonated: updatedUser.has_donated,
|
||||||
|
whatsappNotifications: updatedUser.whatsapp_notifications,
|
||||||
|
createdAt: updatedUser.created_at,
|
||||||
|
updatedAt: updatedUser.updated_at,
|
||||||
|
lastLogin: updatedUser.last_login,
|
||||||
|
isActive: updatedUser.is_active,
|
||||||
|
isVerified: updatedUser.is_verified,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Se for NextResponse (erro de autenticação), re-lançar
|
||||||
|
if (error instanceof NextResponse) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
secureLogger.error("❌ Erro ao atualizar perfil", {
|
||||||
|
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro interno",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { abacatePay } from "@/lib/abacatepay";
|
|
||||||
import { markAsPaid } from "@/lib/payment-cache";
|
import { markAsPaid } from "@/lib/payment-cache";
|
||||||
import {
|
import {
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
getClientIP,
|
|
||||||
createRateLimitResponse,
|
createRateLimitResponse,
|
||||||
|
getClientIP,
|
||||||
} from "@/lib/rate-limiter";
|
} from "@/lib/rate-limiter";
|
||||||
import { secureLogger } from "@/lib/secure-logger";
|
import { secureLogger } from "@/lib/secure-logger";
|
||||||
|
|
||||||
@@ -56,11 +55,11 @@ export async function POST(request: NextRequest) {
|
|||||||
// Rejeitar eventos com mais de 5 minutos de diferença
|
// Rejeitar eventos com mais de 5 minutos de diferença
|
||||||
if (timeDiff > 5 * 60 * 1000) {
|
if (timeDiff > 5 * 60 * 1000) {
|
||||||
secureLogger.warn("Webhook rejeitado: timestamp inválido", {
|
secureLogger.warn("Webhook rejeitado: timestamp inválido", {
|
||||||
timeDiff: timeDiff / 1000 + "s",
|
timeDiff: `${timeDiff / 1000}s`,
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Timestamp inválido" },
|
{ error: "Timestamp inválido" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +118,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
secureLogger.info(
|
secureLogger.info(
|
||||||
"Registro de pagamento criado",
|
"Registro de pagamento criado",
|
||||||
secureLogger.mask(donationRecord)
|
secureLogger.mask(donationRecord),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Salvar no cache para frontend detectar via polling
|
// Salvar no cache para frontend detectar via polling
|
||||||
@@ -150,7 +149,7 @@ export async function POST(request: NextRequest) {
|
|||||||
secureLogger.error("Webhook error", error);
|
secureLogger.error("Webhook error", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Erro ao processar webhook" },
|
{ error: "Erro ao processar webhook" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 96 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Geist_Mono, Nunito_Sans } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const nunitoSans = Nunito_Sans({
|
||||||
|
variable: "--font-nunito-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["200", "300", "400", "500", "600", "700", "800", "900"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Tibia Trade",
|
||||||
|
description:
|
||||||
|
"Marketplace seguro para negociação de itens e serviços de Tibia.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#09090b",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="pt" suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const baseUrl = "https://tibiatrade.evandro.dev.br";
|
||||||
|
const locales = ["pt", "en"];
|
||||||
|
const pages = ["", "/market", "/sobre", "/precos", "/apoiar"];
|
||||||
|
|
||||||
|
const routes: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
locales.forEach((locale) => {
|
||||||
|
pages.forEach((page) => {
|
||||||
|
routes.push({
|
||||||
|
url: `${baseUrl}/${locale}${page}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: page === "" ? "daily" : "weekly",
|
||||||
|
priority: page === "" ? 1.0 : 0.8,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
pt: `${baseUrl}/pt${page}`,
|
||||||
|
en: `${baseUrl}/en${page}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Tibia Trade</title>
|
||||||
|
<desc id="desc">Tibia Trade é um marketplace de itens de Tibia separado por servidor. À direita, um anúncio em leilão do item Grand Sanguine Blade no servidor Antica, com os meios de pagamento aceitos (PIX, Tibia Coins, gold coins e troca) e os botões Dar Lance e Intenção de compra.</desc>
|
||||||
|
|
||||||
|
<rect width="1200" height="420" rx="24" fill="#0a0a0a"/>
|
||||||
|
|
||||||
|
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
|
||||||
|
<text x="64" y="104" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#a1a1a1">pagodera · marketplace</text>
|
||||||
|
<text x="64" y="186" font-size="72" font-weight="700" fill="#fafafa">Tibia Trade</text>
|
||||||
|
<text x="64" y="246" font-size="28" fill="#fafafa">Compre, venda e leiloe itens de Tibia</text>
|
||||||
|
<text x="64" y="282" font-size="28" fill="#fafafa">por servidor, com chat em tempo real.</text>
|
||||||
|
<text x="64" y="344" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#ffd700">Next.js 15.5 · PostgreSQL · Socket.IO</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="project-proof" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
|
||||||
|
<rect x="632" y="40" width="504" height="340" rx="16" fill="#171717" stroke="#2e2e2e" stroke-width="2"/>
|
||||||
|
|
||||||
|
<rect x="664" y="68" width="96" height="36" rx="18" fill="#262626"/>
|
||||||
|
<text x="712" y="92" font-size="18" fill="#fafafa" text-anchor="middle">Antica</text>
|
||||||
|
<rect x="1000" y="68" width="104" height="36" rx="18" fill="#ffd700"/>
|
||||||
|
<text x="1052" y="92" font-size="18" font-weight="700" fill="#0a0a0a" text-anchor="middle">LEILÃO</text>
|
||||||
|
|
||||||
|
<g id="item">
|
||||||
|
<rect x="664" y="124" width="84" height="84" rx="12" fill="#262626"/>
|
||||||
|
<line x1="696" y1="176" x2="734" y2="138" stroke="#e5e5e5" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<line x1="684" y1="170" x2="702" y2="188" stroke="#ffd700" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="690" y1="182" x2="680" y2="192" stroke="#a16207" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="677" cy="195" r="5" fill="#ffd700"/>
|
||||||
|
<text x="768" y="160" font-size="26" font-weight="700" fill="#fafafa">Grand Sanguine Blade</text>
|
||||||
|
<text x="768" y="192" font-size="20" fill="#a1a1a1">Espadas · nível 600 · Knights</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="currencies" font-size="18" fill="#fafafa">
|
||||||
|
<text x="664" y="246" font-family="ui-monospace, 'SF Mono', Consolas, monospace" fill="#a1a1a1">aceita</text>
|
||||||
|
<rect x="664" y="258" width="64" height="36" rx="8" fill="#262626"/>
|
||||||
|
<text x="696" y="282" text-anchor="middle">PIX</text>
|
||||||
|
<rect x="736" y="258" width="128" height="36" rx="8" fill="#262626"/>
|
||||||
|
<text x="800" y="282" text-anchor="middle">Tibia Coins</text>
|
||||||
|
<rect x="872" y="258" width="120" height="36" rx="8" fill="#262626"/>
|
||||||
|
<text x="932" y="282" text-anchor="middle">gold coins</text>
|
||||||
|
<rect x="1000" y="258" width="76" height="36" rx="8" fill="#262626"/>
|
||||||
|
<text x="1038" y="282" text-anchor="middle">troca</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="actions" font-size="20" font-weight="700" text-anchor="middle">
|
||||||
|
<rect x="664" y="316" width="200" height="44" rx="10" fill="#ffd700"/>
|
||||||
|
<text x="764" y="345" fill="#0a0a0a">Dar Lance</text>
|
||||||
|
<rect x="880" y="316" width="224" height="44" rx="10" fill="none" stroke="#ffd700" stroke-width="2"/>
|
||||||
|
<text x="992" y="345" fill="#ffd700">Intenção de compra</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
+136
-9
@@ -1,5 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { LogOut, User } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
NavigationMenuItem,
|
NavigationMenuItem,
|
||||||
@@ -8,24 +23,48 @@ import {
|
|||||||
navigationMenuTriggerStyle,
|
navigationMenuTriggerStyle,
|
||||||
} from "@/components/ui/navigation-menu";
|
} from "@/components/ui/navigation-menu";
|
||||||
import { WordRotate } from "@/components/ui/word-rotate";
|
import { WordRotate } from "@/components/ui/word-rotate";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { useSession } from "@/lib/hooks/use-session";
|
||||||
import { LanguageSwitcher } from "./language-switcher";
|
|
||||||
import { MobileMenu } from "./mobile-menu";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslations, useLocale } from "next-intl";
|
import { LoginDialog } from "./auth/login-dialog";
|
||||||
import Link from "next/link";
|
import { RegisterDialog } from "./auth/register-dialog";
|
||||||
|
import { LanguageSwitcher } from "./language-switcher";
|
||||||
|
import { MessagesBell } from "./messages/MessagesBell";
|
||||||
|
import { MobileMenu } from "./mobile-menu";
|
||||||
|
import { NotificationBell } from "./notifications/NotificationBell";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const t = useTranslations("header.nav");
|
const t = useTranslations("header.nav");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
const { user, isAuthenticated, isLoading, logout } = useSession();
|
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||||
|
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Evitar hidratação mismatch
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
|
{ href: `/${locale}/library`, label: "Library" },
|
||||||
{ href: `/${locale}/market`, label: t("market") },
|
{ href: `/${locale}/market`, label: t("market") },
|
||||||
{ href: `/${locale}/sobre`, label: t("about") },
|
{ href: `/${locale}/sobre`, label: t("about") },
|
||||||
{ href: `/${locale}/precos`, label: t("prices") },
|
{ href: `/${locale}/precos`, label: t("prices") },
|
||||||
{ href: `/${locale}/apoiar`, label: t("support"), highlight: true },
|
{ href: `/${locale}/apoiar`, label: t("support"), highlight: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
...links,
|
||||||
|
...(isAuthenticated && user?.userId === 1
|
||||||
|
? [{ href: `/${locale}/admin`, label: "Admin" }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/60 backdrop-blur supports-backdrop-filter:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b bg-background/60 backdrop-blur supports-backdrop-filter:bg-background/60">
|
||||||
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4">
|
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4">
|
||||||
@@ -33,7 +72,7 @@ export default function Header() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<WordRotate
|
<WordRotate
|
||||||
words={["Pagodera", "Tibia Trade"]}
|
words={["Tibia Trade"]}
|
||||||
className="text-lg font-semibold tracking-tight hover:text-primary transition-colors"
|
className="text-lg font-semibold tracking-tight hover:text-primary transition-colors"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -42,7 +81,7 @@ export default function Header() {
|
|||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<NavigationMenu className="hidden md:flex">
|
<NavigationMenu className="hidden md:flex">
|
||||||
<NavigationMenuList>
|
<NavigationMenuList>
|
||||||
{links.map(({ href, label, highlight }) => (
|
{navLinks.map(({ href, label, highlight }) => (
|
||||||
<NavigationMenuItem key={href}>
|
<NavigationMenuItem key={href}>
|
||||||
<NavigationMenuLink asChild>
|
<NavigationMenuLink asChild>
|
||||||
<Link
|
<Link
|
||||||
@@ -51,7 +90,7 @@ export default function Header() {
|
|||||||
navigationMenuTriggerStyle(),
|
navigationMenuTriggerStyle(),
|
||||||
"bg-transparent",
|
"bg-transparent",
|
||||||
highlight &&
|
highlight &&
|
||||||
"bg-primary text-primary-foreground hover:bg-primary/90"
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -66,13 +105,101 @@ export default function Header() {
|
|||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{mounted && isAuthenticated && <MessagesBell />}
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
{mounted && isAuthenticated && <NotificationBell />}
|
||||||
|
{mounted && isAuthenticated && user?.subscriptionTier && (
|
||||||
|
<Badge variant="outline" className="uppercase">
|
||||||
|
{user.subscriptionTier.slug}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auth Section */}
|
||||||
|
{!mounted ? (
|
||||||
|
// Placeholder durante SSR para evitar mismatch
|
||||||
|
<div className="w-20 h-8 animate-pulse bg-muted rounded" />
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="w-20 h-8 animate-pulse bg-muted rounded" />
|
||||||
|
) : isAuthenticated && user ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="relative h-8 w-8 rounded-full"
|
||||||
|
>
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback>
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
{user.username}
|
||||||
|
</p>
|
||||||
|
{user.characterName && (
|
||||||
|
<p className="text-xs leading-none text-primary font-medium">
|
||||||
|
{user.characterName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs leading-none text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${locale}/perfil`} className="cursor-pointer">
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
Perfil
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-destructive"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
Sair
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRegisterDialogOpen(true)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Registrar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setLoginDialogOpen(true)} size="sm">
|
||||||
|
Entrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<MobileMenu links={links} />
|
<MobileMenu links={navLinks} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Login Dialog */}
|
||||||
|
<LoginDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen} />
|
||||||
|
|
||||||
|
{/* Register Dialog */}
|
||||||
|
<RegisterDialog
|
||||||
|
open={registerDialogOpen}
|
||||||
|
onOpenChange={setRegisterDialogOpen}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AdminSubscriptionTab } from "@/components/admin/AdminSubscriptionTab";
|
||||||
|
import {
|
||||||
|
AdminItemManager,
|
||||||
|
type AdminItemsResponse,
|
||||||
|
} from "@/components/admin/items/AdminItemManager";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
interface AdminPanelTabsProps {
|
||||||
|
adminUser: {
|
||||||
|
subscription_tier?: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
} | null;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
} | null;
|
||||||
|
initialTiers: any[];
|
||||||
|
itemsData: AdminItemsResponse;
|
||||||
|
locale: string;
|
||||||
|
initialTab?: "overview" | "subscriptions" | "items";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPanelTabs({
|
||||||
|
adminUser,
|
||||||
|
initialTiers,
|
||||||
|
itemsData,
|
||||||
|
locale,
|
||||||
|
initialTab = "overview",
|
||||||
|
}: AdminPanelTabsProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<
|
||||||
|
"overview" | "subscriptions" | "items"
|
||||||
|
>(initialTab);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => setActiveTab(value as typeof activeTab)}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Visão Geral</TabsTrigger>
|
||||||
|
<TabsTrigger value="subscriptions">
|
||||||
|
Assinaturas & Permissões
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="items">Gerenciamento de Itens</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Bem-vindo, {adminUser?.username ?? "Administrador"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Utilize as abas acima para administrar planos de assinatura,
|
||||||
|
permissões e a curadoria de itens do marketplace. Esta área é
|
||||||
|
exclusiva para operadores autorizados.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Para solicitações críticas, lembre-se de registrar mudanças e
|
||||||
|
validar com a equipe responsável antes de disponibilizar aos
|
||||||
|
usuários finais.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="subscriptions">
|
||||||
|
<AdminSubscriptionTab
|
||||||
|
initialTiers={initialTiers}
|
||||||
|
adminUser={adminUser}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="items">
|
||||||
|
<AdminItemManager initialData={itemsData} locale={locale} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminSubscriptionDashboard } from "@/components/admin/subscription-dashboard";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface AdminSubscriptionTabProps {
|
||||||
|
initialTiers: any[];
|
||||||
|
adminUser: {
|
||||||
|
subscription_tier?: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSubscriptionTab({
|
||||||
|
initialTiers,
|
||||||
|
adminUser,
|
||||||
|
}: AdminSubscriptionTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Assinaturas & Permissões
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||||
|
Configure planos, recursos e vincule usuários às assinaturas
|
||||||
|
disponíveis. Todas as alterações são aplicadas imediatamente após
|
||||||
|
salvar.
|
||||||
|
</p>
|
||||||
|
{adminUser?.subscription_tier && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Seu plano atual:{" "}
|
||||||
|
<span className="font-semibold uppercase">
|
||||||
|
{adminUser.subscription_tier.name} (
|
||||||
|
{adminUser.subscription_tier.slug})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AdminSubscriptionDashboard initialTiers={initialTiers} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
|
||||||
|
import { ItemAiAssistant, type ItemAiPrefill } from "./ItemAiAssistant";
|
||||||
|
|
||||||
|
interface AdminItemCreatePageProps {
|
||||||
|
locale: string;
|
||||||
|
categories: string[];
|
||||||
|
serverOptions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminItemCreatePage({
|
||||||
|
locale,
|
||||||
|
categories,
|
||||||
|
serverOptions,
|
||||||
|
}: AdminItemCreatePageProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [prefillData, setPrefillData] = useState<ItemAiPrefill | null>(null);
|
||||||
|
|
||||||
|
const goBackToList = () => {
|
||||||
|
router.push(`/${locale}/admin?tab=items`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (payload: ItemFormPayload) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: payload.name,
|
||||||
|
category: payload.category,
|
||||||
|
rarity: payload.rarity,
|
||||||
|
imageUrl: payload.imageUrl,
|
||||||
|
imageLocalPath: payload.imageLocalPath,
|
||||||
|
level: payload.level,
|
||||||
|
weight: payload.weight,
|
||||||
|
slots: payload.slots,
|
||||||
|
skill: payload.skill,
|
||||||
|
description: payload.description,
|
||||||
|
servers: payload.servers,
|
||||||
|
tags: payload.tags,
|
||||||
|
metadata: payload.metadata,
|
||||||
|
fields: payload.fields,
|
||||||
|
server: payload.server ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/api/admin/items", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Erro ao criar item" }));
|
||||||
|
throw new Error(data?.error || data?.message || "Falha ao criar item");
|
||||||
|
}
|
||||||
|
|
||||||
|
goBackToList();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Erro ao criar item. Tente novamente.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Novo item</h1>
|
||||||
|
<p className="text-muted-foreground max-w-2xl">
|
||||||
|
Utilize a seção de IA abaixo para gerar automaticamente campos
|
||||||
|
sugeridos a partir de prints e descrições. Ajuste o que for
|
||||||
|
necessário antes de publicar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={goBackToList} className="gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ItemAiAssistant
|
||||||
|
locale={locale}
|
||||||
|
categories={categories}
|
||||||
|
serverOptions={serverOptions}
|
||||||
|
onGenerated={({ json, prefill }) => {
|
||||||
|
setPrefillData({
|
||||||
|
...prefill,
|
||||||
|
metadata: {
|
||||||
|
aiStructure: json ?? null,
|
||||||
|
...prefill.metadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<AdminItemForm
|
||||||
|
mode="create"
|
||||||
|
categories={categories}
|
||||||
|
serverOptions={serverOptions}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitting={submitting}
|
||||||
|
onCancel={goBackToList}
|
||||||
|
isActive
|
||||||
|
allowAiTools={false}
|
||||||
|
prefillData={prefillData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
|
||||||
|
|
||||||
|
interface AdminItemCreateViewProps {
|
||||||
|
locale: string;
|
||||||
|
categories: string[];
|
||||||
|
serverOptions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminItemCreateView({
|
||||||
|
locale,
|
||||||
|
categories,
|
||||||
|
serverOptions,
|
||||||
|
}: AdminItemCreateViewProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (payload: ItemFormPayload) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: payload.name,
|
||||||
|
category: payload.category,
|
||||||
|
rarity: payload.rarity,
|
||||||
|
imageUrl: payload.imageUrl,
|
||||||
|
imageLocalPath: payload.imageLocalPath,
|
||||||
|
level: payload.level,
|
||||||
|
weight: payload.weight,
|
||||||
|
slots: payload.slots,
|
||||||
|
skill: payload.skill,
|
||||||
|
description: payload.description,
|
||||||
|
servers: payload.servers,
|
||||||
|
tags: payload.tags,
|
||||||
|
metadata: payload.metadata,
|
||||||
|
fields: payload.fields,
|
||||||
|
server: payload.server ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/api/admin/items", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorPayload = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Erro ao criar item" }));
|
||||||
|
throw new Error(errorPayload?.error || "Falha ao criar item");
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/${locale}/admin?tab=items`);
|
||||||
|
router.refresh();
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push(`/${locale}/admin?tab=items`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card shadow-sm">
|
||||||
|
<AdminItemForm
|
||||||
|
mode="create"
|
||||||
|
categories={categories}
|
||||||
|
serverOptions={serverOptions}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
submitting={submitting}
|
||||||
|
className="flex flex-col"
|
||||||
|
contentClassName="px-6 py-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,652 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import { Pencil, Plus, RefreshCw } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ItemCreationModal,
|
||||||
|
type ItemFormPayload,
|
||||||
|
} from "@/components/admin/items/ItemCreationModal";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
export interface AdminItemRecord {
|
||||||
|
item_id: number;
|
||||||
|
source_table_id: number;
|
||||||
|
source_table_name: string;
|
||||||
|
item_name: string;
|
||||||
|
category: string;
|
||||||
|
image_local_path: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
rarity: string | null;
|
||||||
|
level: number | null;
|
||||||
|
weight: number | null;
|
||||||
|
slots: number | null;
|
||||||
|
skill: string | null;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
created_at: string | Date;
|
||||||
|
updated_at: string | Date;
|
||||||
|
servers: string[];
|
||||||
|
tags: string[];
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminItemsResponse {
|
||||||
|
items: AdminItemRecord[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
servers: string[];
|
||||||
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminItemManagerProps {
|
||||||
|
initialData: AdminItemsResponse;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminItemManager({
|
||||||
|
initialData,
|
||||||
|
locale,
|
||||||
|
}: AdminItemManagerProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<AdminItemsResponse>(initialData);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [apiStatus, setApiStatus] = useState<{
|
||||||
|
code: number | null;
|
||||||
|
ok: boolean | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
checkedAt: string | null;
|
||||||
|
}>({ code: null, ok: null, durationMs: null, checkedAt: null });
|
||||||
|
|
||||||
|
const [serverFilter, setServerFilter] = useState<string>("all");
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||||
|
const [rarityFilter, setRarityFilter] = useState<string>("all");
|
||||||
|
const [page, setPage] = useState<number>(initialData.page ?? 1);
|
||||||
|
const pageSize = initialData.pageSize ?? 50;
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [activeServers, setActiveServers] = useState<string[]>([]);
|
||||||
|
const [_serversLoading, setServersLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const searchDebounce = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
function isAdminItemsResponse(val: any): val is AdminItemsResponse {
|
||||||
|
return (
|
||||||
|
val &&
|
||||||
|
Array.isArray(val.items) &&
|
||||||
|
typeof val.total === "number" &&
|
||||||
|
typeof val.page === "number" &&
|
||||||
|
typeof val.pageSize === "number" &&
|
||||||
|
Array.isArray(val.servers) &&
|
||||||
|
Array.isArray(val.categories)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carrega servidores ativos independentemente dos itens cadastrados
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadServers() {
|
||||||
|
try {
|
||||||
|
setServersLoading(true);
|
||||||
|
const res = await fetch("/api/servers", {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(
|
||||||
|
"[AdminItemManager] falha ao buscar servidores ativos:",
|
||||||
|
res.status,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
const names: string[] = Array.isArray(json?.servers)
|
||||||
|
? json.servers
|
||||||
|
.map((s: any) =>
|
||||||
|
typeof s?.serverName === "string" ? s.serverName : null,
|
||||||
|
)
|
||||||
|
.filter((n: string | null): n is string => Boolean(n))
|
||||||
|
: [];
|
||||||
|
if (!cancelled) {
|
||||||
|
setActiveServers(names);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[AdminItemManager] erro ao buscar /api/servers:", e);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setServersLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadServers();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchItems = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search.trim()) params.set("q", search.trim());
|
||||||
|
if (serverFilter !== "all") params.set("server", serverFilter);
|
||||||
|
if (categoryFilter !== "all") params.set("category", categoryFilter);
|
||||||
|
if (rarityFilter !== "all") params.set("rarity", rarityFilter);
|
||||||
|
params.set("page", String(page));
|
||||||
|
params.set("pageSize", String(pageSize));
|
||||||
|
|
||||||
|
const url = `/api/admin/items?${params.toString()}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), 15000);
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
console.group("[AdminItemManager] fetchItems");
|
||||||
|
console.log("URL:", url);
|
||||||
|
console.log("QueryParams:", Object.fromEntries(params.entries()));
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
setApiStatus({
|
||||||
|
code: res.status,
|
||||||
|
ok: res.ok,
|
||||||
|
durationMs,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
"Status:",
|
||||||
|
res.status,
|
||||||
|
res.statusText,
|
||||||
|
"Duration:",
|
||||||
|
`${durationMs}ms`,
|
||||||
|
);
|
||||||
|
console.log("Headers:", {
|
||||||
|
"content-type": res.headers.get("content-type"),
|
||||||
|
"x-powered-by": res.headers.get("x-powered-by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorPayload: any = null;
|
||||||
|
try {
|
||||||
|
errorPayload = await res.json();
|
||||||
|
} catch {
|
||||||
|
// pode não ser JSON
|
||||||
|
}
|
||||||
|
const baseMsg =
|
||||||
|
res.status === 401
|
||||||
|
? "Sessão expirada ou não autenticado. Faça login e tente novamente."
|
||||||
|
: res.status === 403
|
||||||
|
? "Acesso restrito ao administrador."
|
||||||
|
: "Não foi possível carregar os itens.";
|
||||||
|
const apiMsg = errorPayload?.error || errorPayload?.message || null;
|
||||||
|
const composed = apiMsg
|
||||||
|
? `${baseMsg} (${apiMsg})`
|
||||||
|
: `${baseMsg} (HTTP ${res.status})`;
|
||||||
|
console.error("Erro na API:", composed, "Payload:", errorPayload);
|
||||||
|
throw new Error(composed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (!isAdminItemsResponse(json)) {
|
||||||
|
console.error("Resposta inválida da API:", json);
|
||||||
|
throw new Error("Resposta inválida da API de itens.");
|
||||||
|
}
|
||||||
|
setData(json);
|
||||||
|
console.log("Itens carregados:", {
|
||||||
|
total: json.total,
|
||||||
|
page: json.page,
|
||||||
|
pageSize: json.pageSize,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
setApiStatus((prev) => ({
|
||||||
|
code: prev.code,
|
||||||
|
ok: false,
|
||||||
|
durationMs,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
if (err?.name === "AbortError") {
|
||||||
|
console.error("Requisição abortada por timeout");
|
||||||
|
setError("Tempo de resposta excedido. Tente novamente.");
|
||||||
|
} else {
|
||||||
|
console.error("Falha ao carregar itens:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Falha ao carregar itens.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.groupEnd();
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
search,
|
||||||
|
serverFilter,
|
||||||
|
categoryFilter,
|
||||||
|
rarityFilter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
isAdminItemsResponse,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchDebounce.current !== undefined) {
|
||||||
|
window.clearTimeout(searchDebounce.current);
|
||||||
|
}
|
||||||
|
searchDebounce.current = window.setTimeout(() => {
|
||||||
|
fetchItems();
|
||||||
|
}, 350);
|
||||||
|
return () => {
|
||||||
|
if (searchDebounce.current !== undefined) {
|
||||||
|
window.clearTimeout(searchDebounce.current);
|
||||||
|
searchDebounce.current = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchItems]);
|
||||||
|
|
||||||
|
const rarityOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
data.items.forEach((item) => {
|
||||||
|
if (item.rarity) {
|
||||||
|
set.add(item.rarity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(set).sort((a, b) =>
|
||||||
|
a.localeCompare(b, "pt-BR", { sensitivity: "base" }),
|
||||||
|
);
|
||||||
|
}, [data.items]);
|
||||||
|
|
||||||
|
const openEditModal = (item: AdminItemRecord) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (payload: ItemFormPayload) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const method = "PUT";
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
id: payload.id,
|
||||||
|
name: payload.name,
|
||||||
|
category: payload.category,
|
||||||
|
rarity: payload.rarity,
|
||||||
|
imageUrl: payload.imageUrl,
|
||||||
|
imageLocalPath: payload.imageLocalPath,
|
||||||
|
level: payload.level,
|
||||||
|
weight: payload.weight,
|
||||||
|
slots: payload.slots,
|
||||||
|
skill: payload.skill,
|
||||||
|
description: payload.description,
|
||||||
|
servers: payload.servers,
|
||||||
|
tags: payload.tags,
|
||||||
|
metadata: payload.metadata,
|
||||||
|
fields: payload.fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch("/api/admin/items", {
|
||||||
|
method,
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Erro desconhecido" }));
|
||||||
|
throw new Error(
|
||||||
|
error?.error || error?.message || "Falha ao salvar item",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchItems();
|
||||||
|
setModalOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err.message : "Erro ao salvar item.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemsForDisplay = data.items;
|
||||||
|
const serverTabs = ["all", ...data.servers];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle>Todos os itens cadastrados</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Visualize e edite os registros unificados importados do database
|
||||||
|
do Tibia Wiki. Itens específicos de servidores podem ser marcados
|
||||||
|
através das tags.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchItems}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" /> Atualizar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${locale}/admin/items/new`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Novo item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por nome..."
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={categoryFilter}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setCategoryFilter(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Categoria" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todas as categorias</SelectItem>
|
||||||
|
{data.categories.map((category) => (
|
||||||
|
<SelectItem key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={rarityFilter}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setRarityFilter(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Raridade" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todas as raridades</SelectItem>
|
||||||
|
{rarityOptions.map((rarity) => (
|
||||||
|
<SelectItem key={rarity} value={rarity}>
|
||||||
|
{rarity}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
{loading ? "Carregando..." : `${data.total} itens encontrados`}
|
||||||
|
{apiStatus.code !== null && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"}
|
||||||
|
{apiStatus.durationMs !== null
|
||||||
|
? ` • ${apiStatus.durationMs}ms`
|
||||||
|
: ""}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card className="border-destructive/40 bg-destructive/10">
|
||||||
|
<CardContent className="py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-64" />
|
||||||
|
<Skeleton className="h-72 w-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Tabs
|
||||||
|
value={serverFilter}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setServerFilter(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<TabsList className="flex-wrap">
|
||||||
|
{serverTabs.map((server) => (
|
||||||
|
<TabsTrigger key={server} value={server} className="capitalize">
|
||||||
|
{server === "all" ? "Todos" : server}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{serverTabs.map((server) => (
|
||||||
|
<TabsContent key={server} value={server} className="space-y-4">
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<table className="w-full table-auto border-collapse min-w-[960px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/70 text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<th className="border border-border px-3 py-2 text-left">
|
||||||
|
Imagem
|
||||||
|
</th>
|
||||||
|
<th className="border border-border px-3 py-2 text-left">
|
||||||
|
Nome
|
||||||
|
</th>
|
||||||
|
<th className="border border-border px-3 py-2 text-left">
|
||||||
|
Categoria
|
||||||
|
</th>
|
||||||
|
<th className="border border-border px-3 py-2 text-left">
|
||||||
|
Raridade
|
||||||
|
</th>
|
||||||
|
<th className="border border-border px-3 py-2 text-left">
|
||||||
|
Origem
|
||||||
|
</th>
|
||||||
|
<th className="border border-border px-3 py-2 text-left">
|
||||||
|
Servidores
|
||||||
|
</th>
|
||||||
|
<th className="border border-border px-3 py-2 text-left">
|
||||||
|
Atualizado em
|
||||||
|
</th>
|
||||||
|
<th className="border border-border px-3 py-2 text-left">
|
||||||
|
Ações
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{itemsForDisplay.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={8}
|
||||||
|
className="border border-border px-3 py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Nenhum item encontrado com os filtros atuais.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
itemsForDisplay.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.item_id}
|
||||||
|
className="odd:bg-background even:bg-muted/40 hover:bg-primary/5 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="border border-border px-3 py-2 align-middle">
|
||||||
|
{item.image_url ? (
|
||||||
|
<div className="relative h-12 w-12 overflow-hidden rounded border">
|
||||||
|
<Image
|
||||||
|
src={item.image_url}
|
||||||
|
alt={item.item_name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Sem imagem
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="border border-border px-3 py-2 align-top">
|
||||||
|
<div className="font-medium">{item.item_name}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-mono">
|
||||||
|
#{item.item_id}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="border border-border px-3 py-2 align-top text-sm">
|
||||||
|
{item.category}
|
||||||
|
</td>
|
||||||
|
<td className="border border-border px-3 py-2 align-top text-sm">
|
||||||
|
{item.rarity ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="uppercase text-[10px]"
|
||||||
|
>
|
||||||
|
{item.rarity}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
-
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="border border-border px-3 py-2 align-top text-[11px] text-muted-foreground">
|
||||||
|
<div>{item.source_table_name}</div>
|
||||||
|
<div>ID origem: {item.source_table_id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="border border-border px-3 py-2 align-top">
|
||||||
|
{item.servers.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.servers.map((serverTag) => (
|
||||||
|
<Badge
|
||||||
|
key={`${item.item_id}-${serverTag}`}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] uppercase"
|
||||||
|
>
|
||||||
|
{serverTag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Global
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="border border-border px-3 py-2 align-top text-xs text-muted-foreground">
|
||||||
|
{format(
|
||||||
|
new Date(item.updated_at),
|
||||||
|
"dd/MM/yyyy HH:mm",
|
||||||
|
{ locale: ptBR },
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="border border-border px-3 py-2 align-top">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => openEditModal(item)}
|
||||||
|
aria-label="Editar item"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" /> Editar
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Página {page} • Total de{" "}
|
||||||
|
{Math.max(1, Math.ceil(data.total / pageSize))}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setPage((prev) =>
|
||||||
|
prev >= Math.ceil(data.total / pageSize)
|
||||||
|
? prev
|
||||||
|
: prev + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
page >= Math.ceil(data.total / pageSize) || loading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Próxima
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ItemCreationModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
mode="edit"
|
||||||
|
item={selectedItem ?? undefined}
|
||||||
|
categories={data.categories}
|
||||||
|
serverOptions={activeServers.length ? activeServers : data.servers}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, Sparkles } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { ItemImageCapture, type ProcessedImage } from "./ItemImageCapture";
|
||||||
|
|
||||||
|
interface ItemAiAssistantProps {
|
||||||
|
locale: string;
|
||||||
|
categories: string[];
|
||||||
|
serverOptions: string[];
|
||||||
|
onGenerated: (result: { json: unknown; prefill: ItemAiPrefill }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemAiPrefill {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
rarity?: string | null;
|
||||||
|
servers?: string[] | string;
|
||||||
|
tags?: string[] | string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemAiAssistant({
|
||||||
|
locale,
|
||||||
|
categories,
|
||||||
|
serverOptions,
|
||||||
|
onGenerated,
|
||||||
|
}: ItemAiAssistantProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [scope, setScope] = useState<"global" | "server">("global");
|
||||||
|
const [server, setServer] = useState<string>(serverOptions[0] ?? "");
|
||||||
|
const [images, setImages] = useState<ProcessedImage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [rawJson, setRawJson] = useState<string | null>(null);
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Habilita geração se houver ao menos uma imagem válida OU nome+descrição preenchidos
|
||||||
|
const canGenerate = useMemo(() => {
|
||||||
|
const hasValidImage = images.length > 0;
|
||||||
|
const hasTextInputs = name.trim() && description.trim();
|
||||||
|
return Boolean(hasValidImage || hasTextInputs);
|
||||||
|
}, [images.length, name, description]);
|
||||||
|
|
||||||
|
const disabled = useMemo(() => !canGenerate, [canGenerate]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (disabled || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setLogs((prev) => [...prev, "Iniciando geração com IA..."]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLogs((prev) => [
|
||||||
|
...prev,
|
||||||
|
`Payload: nome='${name.trim()}', escopo='${scope}', imagens=${images.length}, servidor='${scope === "server" ? server : "(global)"}'`,
|
||||||
|
]);
|
||||||
|
const response = await fetch("/api/admin/items/analyze", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
locale,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
scope,
|
||||||
|
server: scope === "server" ? server : null,
|
||||||
|
categories,
|
||||||
|
servers: serverOptions,
|
||||||
|
images: images.map((image) => image.dataUrl),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
throw new Error(data?.error || "Falha na análise com IA.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const structure = data?.structure ?? data;
|
||||||
|
const columns = structure?.columns;
|
||||||
|
setLogs((prev) => [
|
||||||
|
...prev,
|
||||||
|
`Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Heurística: sugerir nomes com base em imagens quando não houver texto detectado
|
||||||
|
const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => {
|
||||||
|
if (!Array.isArray(imgs) || imgs.length === 0) return [];
|
||||||
|
const first = imgs[0];
|
||||||
|
const base = first.originalName
|
||||||
|
.replace(/\.[a-zA-Z0-9]+$/, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[_\s]+/g, "-");
|
||||||
|
const common = new Set([
|
||||||
|
"image",
|
||||||
|
"img",
|
||||||
|
"screenshot",
|
||||||
|
"captura",
|
||||||
|
"photo",
|
||||||
|
"foto",
|
||||||
|
"unknown",
|
||||||
|
]);
|
||||||
|
const orientation = first.height > first.width ? "retrato" : "paisagem";
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
if (base && !common.has(base)) suggestions.push(base);
|
||||||
|
suggestions.push(`${orientation}-1`);
|
||||||
|
suggestions.push("foto-1");
|
||||||
|
return Array.from(new Set(suggestions)).slice(0, 3);
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoNameDetected =
|
||||||
|
typeof columns?.name === "string" &&
|
||||||
|
Boolean(String(columns.name).trim());
|
||||||
|
const autoDescDetected =
|
||||||
|
typeof columns?.description === "string" &&
|
||||||
|
Boolean(String(columns.description).trim());
|
||||||
|
|
||||||
|
const origemDados: string =
|
||||||
|
autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
|
||||||
|
|
||||||
|
const sugestoesNome = !autoNameDetected
|
||||||
|
? suggestNamesFromImages(images)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Estrutura final com campos obrigatórios, origem e sugestões
|
||||||
|
const finalStructure = {
|
||||||
|
...structure,
|
||||||
|
columns: {
|
||||||
|
...columns,
|
||||||
|
origem_dados: origemDados,
|
||||||
|
confirmar_nome: autoNameDetected ? false : undefined,
|
||||||
|
mensagens: {
|
||||||
|
obrigatorio: ["nome"],
|
||||||
|
dicas: autoNameDetected
|
||||||
|
? "Confirme o nome sugerido ou ajuste se necessário."
|
||||||
|
: "Informe um nome para o item. Descrição é opcional, mas ajuda.",
|
||||||
|
},
|
||||||
|
sugestoes:
|
||||||
|
sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setRawJson(JSON.stringify(finalStructure, null, 2));
|
||||||
|
|
||||||
|
const prefill: ItemAiPrefill = {
|
||||||
|
name: autoNameDetected ? (columns?.name as string) : undefined,
|
||||||
|
description: autoDescDetected
|
||||||
|
? (columns?.description as string)
|
||||||
|
: undefined,
|
||||||
|
metadata:
|
||||||
|
typeof columns === "object" && columns !== null
|
||||||
|
? {
|
||||||
|
...columns,
|
||||||
|
origem_dados: origemDados,
|
||||||
|
confirmar_nome: autoNameDetected ? false : undefined,
|
||||||
|
sugestoes: sugestoesNome.length
|
||||||
|
? { nome: sugestoesNome }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
origem_dados: origemDados,
|
||||||
|
confirmar_nome: autoNameDetected ? false : undefined,
|
||||||
|
sugestoes: sugestoesNome.length
|
||||||
|
? { nome: sugestoesNome }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (columns) {
|
||||||
|
if (typeof columns.category === "string") {
|
||||||
|
prefill.category = columns.category;
|
||||||
|
}
|
||||||
|
if (Array.isArray(columns.servers)) {
|
||||||
|
prefill.servers = columns.servers as string[];
|
||||||
|
}
|
||||||
|
if (typeof columns.servers === "string") {
|
||||||
|
prefill.servers = columns.servers;
|
||||||
|
}
|
||||||
|
if (Array.isArray(columns.tags)) {
|
||||||
|
prefill.tags = columns.tags as string[];
|
||||||
|
}
|
||||||
|
if (typeof columns.tags === "string") {
|
||||||
|
prefill.tags = columns.tags;
|
||||||
|
}
|
||||||
|
if (columns.rarity && typeof columns.rarity === "string") {
|
||||||
|
prefill.rarity = columns.rarity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogs((prev) => [
|
||||||
|
...prev,
|
||||||
|
"Sugestões de preenchimento geradas. Aguardando confirmação do usuário.",
|
||||||
|
]);
|
||||||
|
onGenerated({ json: finalStructure, prefill });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Erro ao processar análise de IA.",
|
||||||
|
);
|
||||||
|
setLogs((prev) => [
|
||||||
|
...prev,
|
||||||
|
`Erro: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLogs((prev) => [...prev, "Processo de geração concluído."]);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
categories,
|
||||||
|
description,
|
||||||
|
disabled,
|
||||||
|
images,
|
||||||
|
locale,
|
||||||
|
loading,
|
||||||
|
name,
|
||||||
|
onGenerated,
|
||||||
|
scope,
|
||||||
|
server,
|
||||||
|
serverOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-2xl font-semibold flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
Assistente IA para registro de item
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||||
|
Cole prints e descreva o item. A IA vai sugerir a tabela, campos e
|
||||||
|
metadados. Você pode ajustar os dados antes de salvar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
Até 2 imagens · Compressão automática · Suporte a Ctrl+V
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1.1fr,0.9fr]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Nome do item</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="Ex.: Final Fate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Escopo</label>
|
||||||
|
<Select
|
||||||
|
value={scope}
|
||||||
|
onValueChange={(value: "global" | "server") => setScope(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="global">Global</SelectItem>
|
||||||
|
<SelectItem value="server">Específico de servidor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scope === "server" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Servidor alvo</label>
|
||||||
|
<Select value={server} onValueChange={setServer}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione o servidor" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{serverOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Descrição detalhada para a IA
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
placeholder="Cole aqui todo o texto do item (atributos, requisitos, efeitos, etc.)"
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Prints / evidências visuais
|
||||||
|
</label>
|
||||||
|
<ItemImageCapture onImagesChange={setImages} />
|
||||||
|
{images.length > 0 && (
|
||||||
|
<p className="text-xs text-emerald-600">
|
||||||
|
Imagem recebida — pronta para processamento.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Gerar com IA
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A IA retornará um JSON com sugestões de campos. As alterações só
|
||||||
|
serão aplicadas após sua confirmação.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="min-h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Prévia da resposta</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-xs text-muted-foreground">
|
||||||
|
{rawJson ? (
|
||||||
|
<pre className="max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3">
|
||||||
|
{rawJson}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Execute a análise para visualizar o JSON sugerido. Ele será
|
||||||
|
usado para preencher automaticamente os campos do formulário.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Logs do processo
|
||||||
|
</p>
|
||||||
|
<div className="rounded-md border bg-muted/30 p-2">
|
||||||
|
{logs.map((line, idx) => (
|
||||||
|
<p key={idx} className="text-[11px] leading-relaxed">
|
||||||
|
• {line}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdminItemForm,
|
||||||
|
type AdminItemFormProps,
|
||||||
|
type ItemFormPayload,
|
||||||
|
} from "./AdminItemForm";
|
||||||
|
|
||||||
|
interface ItemCreationModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
mode: "create" | "edit";
|
||||||
|
item?: AdminItemFormProps["item"];
|
||||||
|
categories: string[];
|
||||||
|
serverOptions: string[];
|
||||||
|
onSubmit: (payload: ItemFormPayload) => Promise<void>;
|
||||||
|
submitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemCreationModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
mode,
|
||||||
|
item,
|
||||||
|
categories,
|
||||||
|
serverOptions,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
}: ItemCreationModalProps) {
|
||||||
|
return (
|
||||||
|
<AdminItemForm
|
||||||
|
mode={mode}
|
||||||
|
item={item}
|
||||||
|
categories={categories}
|
||||||
|
serverOptions={serverOptions}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
submitting={submitting}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
isActive={open}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reexporta o tipo para permitir importação de ItemFormPayload a partir de ItemCreationModal
|
||||||
|
export type { ItemFormPayload } from "./AdminItemForm";
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Upload, X } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { resizeImageToDataURL } from "@/lib/image/resize";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface ProcessedImage {
|
||||||
|
id: string;
|
||||||
|
originalName: string;
|
||||||
|
previewUrl: string;
|
||||||
|
dataUrl: string;
|
||||||
|
mimeType: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemImageCaptureProps {
|
||||||
|
maxImages?: number;
|
||||||
|
className?: string;
|
||||||
|
onImagesChange?: (images: ProcessedImage[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemImageCapture({
|
||||||
|
maxImages = 2,
|
||||||
|
className,
|
||||||
|
onImagesChange,
|
||||||
|
}: ItemImageCaptureProps) {
|
||||||
|
const generateId = useCallback(() => {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `img-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [images, setImages] = useState<ProcessedImage[]>([]);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const previewUrlsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const updateImages = useCallback(
|
||||||
|
(next: ProcessedImage[]) => {
|
||||||
|
setImages(next);
|
||||||
|
onImagesChange?.(next);
|
||||||
|
},
|
||||||
|
[onImagesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
async (files: FileList | File[]) => {
|
||||||
|
const fileArray = Array.from(files).slice(0, maxImages - images.length);
|
||||||
|
if (fileArray.length === 0) return;
|
||||||
|
|
||||||
|
const processed: ProcessedImage[] = [];
|
||||||
|
|
||||||
|
for (const file of fileArray) {
|
||||||
|
if (!file.type.startsWith("image/")) continue;
|
||||||
|
try {
|
||||||
|
const { dataUrl, width, height } = await resizeImageToDataURL(file, {
|
||||||
|
mimeType: file.type === "image/png" ? "image/png" : "image/jpeg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
previewUrlsRef.current.add(previewUrl);
|
||||||
|
|
||||||
|
processed.push({
|
||||||
|
id: generateId(),
|
||||||
|
originalName: file.name,
|
||||||
|
previewUrl,
|
||||||
|
dataUrl,
|
||||||
|
mimeType: file.type === "image/png" ? "image/png" : "image/jpeg",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Falha ao processar imagem", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processed.length > 0) {
|
||||||
|
updateImages([...images, ...processed].slice(0, maxImages));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[generateId, images, maxImages, updateImages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
async (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer.files?.length) {
|
||||||
|
await handleFiles(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaste = useCallback(
|
||||||
|
async (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
const files: File[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith("image/")) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length) {
|
||||||
|
await handleFiles(files);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeImage = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const nextImage = images.find((image) => image.id === id);
|
||||||
|
if (nextImage) {
|
||||||
|
URL.revokeObjectURL(nextImage.previewUrl);
|
||||||
|
previewUrlsRef.current.delete(nextImage.previewUrl);
|
||||||
|
}
|
||||||
|
const next = images.filter((image) => image.id !== id);
|
||||||
|
updateImages(next);
|
||||||
|
},
|
||||||
|
[images, updateImages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canAddMore = images.length < maxImages;
|
||||||
|
|
||||||
|
const dropLabel = useMemo(() => {
|
||||||
|
if (images.length === 0) {
|
||||||
|
return "Arraste, cole (Ctrl+V) ou escolha imagens";
|
||||||
|
}
|
||||||
|
if (canAddMore) {
|
||||||
|
return "Adicione mais imagens ou cole via Ctrl+V";
|
||||||
|
}
|
||||||
|
return "Limite máximo de imagens atingido";
|
||||||
|
}, [images.length, canAddMore]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
previewUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
previewUrlsRef.current.clear();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border-2 border-dashed border-muted-foreground/40 bg-muted/40 p-6 text-center transition-colors",
|
||||||
|
canAddMore ? "hover:border-primary" : "opacity-70",
|
||||||
|
)}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
}}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onPaste={onPaste}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">{dropLabel}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={!canAddMore}
|
||||||
|
>
|
||||||
|
Selecionar arquivo
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (event) => {
|
||||||
|
if (event.target.files?.length) {
|
||||||
|
await handleFiles(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{images.map((image) => (
|
||||||
|
<div
|
||||||
|
key={image.id}
|
||||||
|
className="relative overflow-hidden rounded-md border bg-background shadow-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 text-muted-foreground transition hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
onClick={() => removeImage(image.id)}
|
||||||
|
aria-label="Remover imagem"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-emerald-600/90 px-2 py-0.5 text-[11px] text-white">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||||
|
Pronta
|
||||||
|
</span>
|
||||||
|
<img
|
||||||
|
src={image.previewUrl}
|
||||||
|
alt={image.originalName}
|
||||||
|
className="h-48 w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="border-t p-3 text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium">{image.originalName}</p>
|
||||||
|
<p>
|
||||||
|
{image.width}×{image.height}px ·{" "}
|
||||||
|
{Math.round((image.dataUrl.length * 3) / 4 / 1024)}KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
|
||||||
|
|
||||||
|
type FeedbackState =
|
||||||
|
| { type: "success"; message: string }
|
||||||
|
| { type: "error"; message: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
interface AdminSubscriptionDashboardProps {
|
||||||
|
initialTiers: SubscriptionTierSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TierFormState {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
level: number;
|
||||||
|
priceMonthly: string;
|
||||||
|
priceYearly: string;
|
||||||
|
isActive: boolean;
|
||||||
|
features: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTierToFormState(tier: SubscriptionTierSummary): TierFormState {
|
||||||
|
return {
|
||||||
|
name: tier.name,
|
||||||
|
slug: tier.slug,
|
||||||
|
description: tier.description ?? "",
|
||||||
|
level: tier.level,
|
||||||
|
priceMonthly: tier.price_monthly ? tier.price_monthly.toString() : "",
|
||||||
|
priceYearly: tier.price_yearly ? tier.price_yearly.toString() : "",
|
||||||
|
isActive: tier.is_active,
|
||||||
|
features: (tier.features ?? [])
|
||||||
|
.map(
|
||||||
|
(feature) =>
|
||||||
|
`${feature.feature_key}|${feature.label}|${feature.description ?? ""}`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeatures(text: string) {
|
||||||
|
if (!text.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line, index) => {
|
||||||
|
const [featureKey, label, description] = line
|
||||||
|
.split("|")
|
||||||
|
.map((part) => part?.trim() ?? "");
|
||||||
|
return {
|
||||||
|
feature_key: featureKey,
|
||||||
|
label: label || featureKey,
|
||||||
|
description: description || null,
|
||||||
|
sort_order: index,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSubscriptionDashboard({
|
||||||
|
initialTiers,
|
||||||
|
}: AdminSubscriptionDashboardProps) {
|
||||||
|
const [tiers, setTiers] = useState<SubscriptionTierSummary[]>(initialTiers);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [feedback, setFeedback] = useState<FeedbackState>(null);
|
||||||
|
const [createForm, setCreateForm] = useState<TierFormState>({
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
description: "",
|
||||||
|
level: 0,
|
||||||
|
priceMonthly: "",
|
||||||
|
priceYearly: "",
|
||||||
|
isActive: true,
|
||||||
|
features: "",
|
||||||
|
});
|
||||||
|
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(
|
||||||
|
() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
initialTiers.map((tier) => [
|
||||||
|
tier.subscription_tier_id,
|
||||||
|
mapTierToFormState(tier),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [assignUserId, setAssignUserId] = useState("");
|
||||||
|
const [assignTierId, setAssignTierId] = useState<string>("free");
|
||||||
|
|
||||||
|
const resetFeedback = () => setFeedback(null);
|
||||||
|
|
||||||
|
const refreshTiers = useCallback(async () => {
|
||||||
|
const response = await fetch("/api/subscription-tiers", {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Erro ao atualizar lista de planos.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setTiers(data.tiers);
|
||||||
|
setEditForms(
|
||||||
|
Object.fromEntries(
|
||||||
|
data.tiers.map((tier: SubscriptionTierSummary) => [
|
||||||
|
tier.subscription_tier_id,
|
||||||
|
mapTierToFormState(tier),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateTier = async () => {
|
||||||
|
resetFeedback();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: createForm.name,
|
||||||
|
slug: createForm.slug,
|
||||||
|
description: createForm.description || null,
|
||||||
|
level: Number.parseInt(createForm.level.toString(), 10),
|
||||||
|
priceMonthly: createForm.priceMonthly
|
||||||
|
? Number.parseFloat(createForm.priceMonthly)
|
||||||
|
: null,
|
||||||
|
priceYearly: createForm.priceYearly
|
||||||
|
? Number.parseFloat(createForm.priceYearly)
|
||||||
|
: null,
|
||||||
|
isActive: createForm.isActive,
|
||||||
|
features: parseFeatures(createForm.features),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/api/subscription-tiers", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "Erro ao criar plano.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateForm({
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
description: "",
|
||||||
|
level: 0,
|
||||||
|
priceMonthly: "",
|
||||||
|
priceYearly: "",
|
||||||
|
isActive: true,
|
||||||
|
features: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshTiers();
|
||||||
|
setFeedback({ type: "success", message: "Plano criado com sucesso." });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Não foi possível criar o plano.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTier = async (tierId: number) => {
|
||||||
|
resetFeedback();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const form = editForms[tierId];
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
slug: form.slug,
|
||||||
|
description: form.description || null,
|
||||||
|
level: Number.parseInt(form.level.toString(), 10),
|
||||||
|
priceMonthly: form.priceMonthly
|
||||||
|
? Number.parseFloat(form.priceMonthly)
|
||||||
|
: null,
|
||||||
|
priceYearly: form.priceYearly
|
||||||
|
? Number.parseFloat(form.priceYearly)
|
||||||
|
: null,
|
||||||
|
isActive: form.isActive,
|
||||||
|
features: parseFeatures(form.features),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`/api/subscription-tiers/${tierId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "Erro ao atualizar plano.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshTiers();
|
||||||
|
setFeedback({
|
||||||
|
type: "success",
|
||||||
|
message: "Plano atualizado com sucesso.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Não foi possível atualizar o plano.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTier = async (tierId: number, slug: string) => {
|
||||||
|
resetFeedback();
|
||||||
|
|
||||||
|
if (!confirm(`Deseja realmente remover o plano "${slug}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/subscription-tiers/${tierId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "Erro ao remover plano.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshTiers();
|
||||||
|
setFeedback({ type: "success", message: "Plano removido com sucesso." });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Não foi possível remover o plano.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssignTier = async () => {
|
||||||
|
resetFeedback();
|
||||||
|
|
||||||
|
if (!assignUserId.trim()) {
|
||||||
|
setFeedback({
|
||||||
|
type: "error",
|
||||||
|
message: "Informe um ID de usuário válido.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
userId: Number.parseInt(assignUserId, 10),
|
||||||
|
tierSlug: assignTierId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/api/subscription-tiers/assign", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "Erro ao atualizar plano do usuário.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssignUserId("");
|
||||||
|
setFeedback({ type: "success", message: "Plano do usuário atualizado." });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Não foi possível atualizar o plano do usuário.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderedTiers = useMemo(
|
||||||
|
() =>
|
||||||
|
[...tiers].sort((a, b) => {
|
||||||
|
if (a.level === b.level) {
|
||||||
|
return a.subscription_tier_id - b.subscription_tier_id;
|
||||||
|
}
|
||||||
|
return a.level - b.level;
|
||||||
|
}),
|
||||||
|
[tiers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{feedback && (
|
||||||
|
<div
|
||||||
|
className={`rounded-md border px-4 py-3 text-sm ${
|
||||||
|
feedback.type === "success"
|
||||||
|
? "border-emerald-300 bg-emerald-100 text-emerald-900"
|
||||||
|
: "border-destructive/40 bg-destructive/10 text-destructive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{feedback.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Adicionar novo plano</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="tier-name">
|
||||||
|
Nome
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="tier-name"
|
||||||
|
placeholder="Ex.: Ultra"
|
||||||
|
value={createForm.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="tier-slug">
|
||||||
|
Slug
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="tier-slug"
|
||||||
|
placeholder="ex.: ultra"
|
||||||
|
value={createForm.slug}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
slug: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="tier-level">
|
||||||
|
Nível (herança)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="tier-level"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={createForm.level}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
level: Number.parseInt(event.target.value || "0", 10),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="tier-status">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
id="tier-status"
|
||||||
|
variant={createForm.isActive ? "default" : "outline"}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isActive: !prev.isActive,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{createForm.isActive ? "Ativo" : "Inativo"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="tier-price-monthly"
|
||||||
|
>
|
||||||
|
Preço mensal (opcional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="tier-price-monthly"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={createForm.priceMonthly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
priceMonthly: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="tier-price-yearly"
|
||||||
|
>
|
||||||
|
Preço anual (opcional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="tier-price-yearly"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={createForm.priceYearly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
priceYearly: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="tier-description">
|
||||||
|
Descrição
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="tier-description"
|
||||||
|
rows={3}
|
||||||
|
value={createForm.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="tier-features">
|
||||||
|
Recursos (um por linha: featureKey|Label|Descrição)
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="tier-features"
|
||||||
|
rows={4}
|
||||||
|
value={createForm.features}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCreateForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
features: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="premium.analytics|Analytics avançadas|Painéis detalhados"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateTier}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Criar plano
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Planos cadastrados</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{orderedTiers.map((tier) => {
|
||||||
|
const formState =
|
||||||
|
editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={tier.subscription_tier_id} className="border-muted">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-xl">
|
||||||
|
{tier.name}
|
||||||
|
<Badge variant="secondary">{tier.slug}</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={tier.is_active ? "default" : "outline"}
|
||||||
|
className="uppercase"
|
||||||
|
>
|
||||||
|
{tier.is_active ? "Ativo" : "Inativo"}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nível {tier.level} · ID #{tier.subscription_tier_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Nome</label>
|
||||||
|
<Input
|
||||||
|
value={formState.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tier.subscription_tier_id]: {
|
||||||
|
...prev[tier.subscription_tier_id],
|
||||||
|
name: event.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Slug</label>
|
||||||
|
<Input
|
||||||
|
value={formState.slug}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tier.subscription_tier_id]: {
|
||||||
|
...prev[tier.subscription_tier_id],
|
||||||
|
slug: event.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={tier.slug === "free"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Nível</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={formState.level}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tier.subscription_tier_id]: {
|
||||||
|
...prev[tier.subscription_tier_id],
|
||||||
|
level: Number.parseInt(
|
||||||
|
event.target.value || "0",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Status</label>
|
||||||
|
<Button
|
||||||
|
variant={formState.isActive ? "default" : "outline"}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tier.subscription_tier_id]: {
|
||||||
|
...prev[tier.subscription_tier_id],
|
||||||
|
isActive:
|
||||||
|
!prev[tier.subscription_tier_id].isActive,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formState.isActive ? "Ativo" : "Inativo"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Preço mensal
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={formState.priceMonthly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tier.subscription_tier_id]: {
|
||||||
|
...prev[tier.subscription_tier_id],
|
||||||
|
priceMonthly: event.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Preço anual</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={formState.priceYearly}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tier.subscription_tier_id]: {
|
||||||
|
...prev[tier.subscription_tier_id],
|
||||||
|
priceYearly: event.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Descrição</label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={formState.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tier.subscription_tier_id]: {
|
||||||
|
...prev[tier.subscription_tier_id],
|
||||||
|
description: event.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Recursos (um por linha: featureKey|Label|Descrição)
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
value={formState.features}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tier.subscription_tier_id]: {
|
||||||
|
...prev[tier.subscription_tier_id],
|
||||||
|
features: event.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleUpdateTier(tier.subscription_tier_id)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Salvar alterações
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tier.subscription_tier_id]: mapTierToFormState(tier),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Descartar mudanças
|
||||||
|
</Button>
|
||||||
|
{tier.slug !== "free" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteTier(tier.subscription_tier_id, tier.slug)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Remover plano
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vincular plano a usuário</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="assign-user-id">
|
||||||
|
ID do usuário
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="assign-user-id"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={assignUserId}
|
||||||
|
onChange={(event) => setAssignUserId(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="assign-tier">
|
||||||
|
Plano de destino
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="assign-tier"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
value={assignTierId}
|
||||||
|
onChange={(event) => setAssignTierId(event.target.value)}
|
||||||
|
>
|
||||||
|
{orderedTiers.map((tier) => (
|
||||||
|
<option key={tier.subscription_tier_id} value={tier.slug}>
|
||||||
|
{tier.name} ({tier.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={handleAssignTier} disabled={isLoading}>
|
||||||
|
Atualizar plano do usuário
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useSession } from "@/lib/hooks/use-session";
|
||||||
|
|
||||||
|
interface LoginDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
|
||||||
|
const [emailOrUsername, setEmailOrUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
emailOrUsername,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Atualizar sessão
|
||||||
|
await refreshSession();
|
||||||
|
// Fechar diálogo
|
||||||
|
onOpenChange(false);
|
||||||
|
// Limpar formulário
|
||||||
|
setEmailOrUsername("");
|
||||||
|
setPassword("");
|
||||||
|
// Recarregar página para atualizar estado
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
setError(data.error || "Erro ao fazer login");
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
setError("Erro ao conectar com o servidor");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Entrar</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Faça login para acessar sua conta
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="emailOrUsername">Email ou Username</Label>
|
||||||
|
<Input
|
||||||
|
id="emailOrUsername"
|
||||||
|
type="text"
|
||||||
|
placeholder="[email protected] ou username"
|
||||||
|
value={emailOrUsername}
|
||||||
|
onChange={(e) => setEmailOrUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Senha</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? "Entrando..." : "Entrar"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown, X } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useServers } from "@/lib/hooks/use-servers";
|
||||||
|
import { useSession } from "@/lib/hooks/use-session";
|
||||||
|
import {
|
||||||
|
COUNTRIES,
|
||||||
|
type Country,
|
||||||
|
DEFAULT_COUNTRY,
|
||||||
|
} from "@/lib/utils/countries";
|
||||||
|
import {
|
||||||
|
checkPasswordStrength,
|
||||||
|
combinePhoneWithDialCode,
|
||||||
|
formatInternationalPhone,
|
||||||
|
getPhonePlaceholder,
|
||||||
|
unformatPhone,
|
||||||
|
validatePasswordRequirements,
|
||||||
|
} from "@/lib/utils/formatters";
|
||||||
|
|
||||||
|
interface RegisterDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerSelection {
|
||||||
|
serverName: string;
|
||||||
|
characterName: string;
|
||||||
|
isMain: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||||
|
const t = useTranslations("auth.register");
|
||||||
|
const { refreshSession } = useSession();
|
||||||
|
const {
|
||||||
|
servers,
|
||||||
|
isLoading: serversLoading,
|
||||||
|
error: serversError,
|
||||||
|
} = useServers();
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [selectedCountry, setSelectedCountry] =
|
||||||
|
useState<Country>(DEFAULT_COUNTRY);
|
||||||
|
const [countrySearch, setCountrySearch] = useState("");
|
||||||
|
|
||||||
|
// Servers selection
|
||||||
|
const [selectedServers, setSelectedServers] = useState<ServerSelection[]>([]);
|
||||||
|
const [serverSearch, setServerSearch] = useState("");
|
||||||
|
const [showServerDropdown, setShowServerDropdown] = useState(false);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [passwordStrength, setPasswordStrength] = useState<
|
||||||
|
"weak" | "medium" | "strong"
|
||||||
|
>("weak");
|
||||||
|
|
||||||
|
// Filtered servers based on search
|
||||||
|
const filteredServers = useMemo(() => {
|
||||||
|
if (!serverSearch.trim()) return servers;
|
||||||
|
const searchLower = serverSearch.toLowerCase();
|
||||||
|
return servers.filter(
|
||||||
|
(s) =>
|
||||||
|
s.serverName.toLowerCase().includes(searchLower) &&
|
||||||
|
!selectedServers.some((sel) => sel.serverName === s.serverName),
|
||||||
|
);
|
||||||
|
}, [serverSearch, servers, selectedServers]);
|
||||||
|
|
||||||
|
// Update password strength
|
||||||
|
useEffect(() => {
|
||||||
|
if (password) {
|
||||||
|
setPasswordStrength(checkPasswordStrength(password));
|
||||||
|
} else {
|
||||||
|
setPasswordStrength("weak");
|
||||||
|
}
|
||||||
|
}, [password]);
|
||||||
|
|
||||||
|
// Reset form when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setUsername("");
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setFirstName("");
|
||||||
|
setLastName("");
|
||||||
|
setPhone("");
|
||||||
|
setSelectedCountry(DEFAULT_COUNTRY);
|
||||||
|
setCountrySearch("");
|
||||||
|
setSelectedServers([]);
|
||||||
|
setServerSearch("");
|
||||||
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
setPasswordStrength("weak");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const addServer = (serverName: string) => {
|
||||||
|
if (selectedServers.some((s) => s.serverName === serverName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirst = selectedServers.length === 0;
|
||||||
|
setSelectedServers([
|
||||||
|
...selectedServers,
|
||||||
|
{
|
||||||
|
serverName,
|
||||||
|
characterName: "",
|
||||||
|
isMain: isFirst,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setServerSearch("");
|
||||||
|
setShowServerDropdown(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeServer = (index: number) => {
|
||||||
|
const newServers = selectedServers.filter((_, i) => i !== index);
|
||||||
|
// If we removed the main character and there are still servers, make first one main
|
||||||
|
if (selectedServers[index].isMain && newServers.length > 0) {
|
||||||
|
newServers[0].isMain = true;
|
||||||
|
}
|
||||||
|
setSelectedServers(newServers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateServerCharacter = (index: number, characterName: string) => {
|
||||||
|
const newServers = [...selectedServers];
|
||||||
|
newServers[index].characterName = characterName;
|
||||||
|
setSelectedServers(newServers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMainServer = (index: number) => {
|
||||||
|
const newServers = selectedServers.map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
isMain: i === index,
|
||||||
|
}));
|
||||||
|
setSelectedServers(newServers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhoneChange = (value: string) => {
|
||||||
|
const formatted = formatInternationalPhone(
|
||||||
|
value,
|
||||||
|
selectedCountry.dialCode,
|
||||||
|
selectedCountry.code,
|
||||||
|
);
|
||||||
|
setPhone(formatted);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtrar países para busca
|
||||||
|
const filteredCountries = useMemo(() => {
|
||||||
|
if (!countrySearch.trim()) return COUNTRIES;
|
||||||
|
const searchLower = countrySearch.toLowerCase();
|
||||||
|
return COUNTRIES.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(searchLower) ||
|
||||||
|
c.dialCode.includes(searchLower) ||
|
||||||
|
c.code.toLowerCase().includes(searchLower),
|
||||||
|
);
|
||||||
|
}, [countrySearch]);
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!username.trim()) {
|
||||||
|
errors.username = "Username é obrigatório";
|
||||||
|
} else if (username.length < 3 || username.length > 50) {
|
||||||
|
errors.username = "Username deve ter entre 3 e 50 caracteres";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email.trim()) {
|
||||||
|
errors.email = "Email é obrigatório";
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
errors.email = "Email inválido";
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordValidation = validatePasswordRequirements(password);
|
||||||
|
if (!password) {
|
||||||
|
errors.password = "Senha é obrigatória";
|
||||||
|
} else if (!passwordValidation.isValid) {
|
||||||
|
errors.password = passwordValidation.errors[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
errors.confirmPassword = t("errors.passwordMismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstName.trim()) {
|
||||||
|
errors.firstName = "Nome é obrigatório";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastName.trim()) {
|
||||||
|
errors.lastName = "Sobrenome é obrigatório";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedServers.length === 0) {
|
||||||
|
errors.servers = t("errors.noServers");
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedServers.forEach((server, index) => {
|
||||||
|
if (!server.characterName.trim()) {
|
||||||
|
errors[`server_${index}`] = t("errors.characterNameRequired");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFieldErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: username.trim().toLowerCase(),
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
password,
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
phone: phone
|
||||||
|
? combinePhoneWithDialCode(selectedCountry.dialCode, phone)
|
||||||
|
: undefined,
|
||||||
|
servers: selectedServers.map((s) => ({
|
||||||
|
serverName: s.serverName,
|
||||||
|
characterName: s.characterName.trim(),
|
||||||
|
isMain: s.isMain,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verificar se a resposta é JSON antes de fazer parse
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (!contentType || !contentType.includes("application/json")) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error(
|
||||||
|
"Resposta não é JSON do registro:",
|
||||||
|
text.substring(0, 200),
|
||||||
|
);
|
||||||
|
setError("Erro: resposta inválida do servidor");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
await refreshSession();
|
||||||
|
onOpenChange(false);
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
if (data.details) {
|
||||||
|
// Field-specific errors
|
||||||
|
const detailsErrors: Record<string, string> = {};
|
||||||
|
data.details.forEach((detail: { field: string; message: string }) => {
|
||||||
|
detailsErrors[detail.field] = detail.message;
|
||||||
|
});
|
||||||
|
setFieldErrors(detailsErrors);
|
||||||
|
} else {
|
||||||
|
setError(data.error || "Erro ao criar conta");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
setError("Erro ao conectar com o servidor");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasswordStrengthColor = () => {
|
||||||
|
switch (passwordStrength) {
|
||||||
|
case "weak":
|
||||||
|
return "bg-red-500";
|
||||||
|
case "medium":
|
||||||
|
return "bg-yellow-500";
|
||||||
|
case "strong":
|
||||||
|
return "bg-green-500";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Credentials Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold">Credenciais</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">{t("username")}</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("usernamePlaceholder")}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={fieldErrors.username ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{fieldErrors.username && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{fieldErrors.username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">{t("email")}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder={t("emailPlaceholder")}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={fieldErrors.email ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{fieldErrors.email && (
|
||||||
|
<p className="text-sm text-destructive">{fieldErrors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">{t("password")}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder={t("passwordPlaceholder")}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={fieldErrors.password ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{password && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${getPasswordStrengthColor()} transition-all`}
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
passwordStrength === "weak"
|
||||||
|
? "33%"
|
||||||
|
: passwordStrength === "medium"
|
||||||
|
? "66%"
|
||||||
|
: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t(`passwordStrength.${passwordStrength}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fieldErrors.password && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{fieldErrors.password}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">{t("confirmPassword")}</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder={t("confirmPasswordPlaceholder")}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={
|
||||||
|
fieldErrors.confirmPassword ? "border-destructive" : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{fieldErrors.confirmPassword && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{fieldErrors.confirmPassword}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Personal Info Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold">Dados Pessoais</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firstName">{t("firstName")}</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("firstNamePlaceholder")}
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={fieldErrors.firstName ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{fieldErrors.firstName && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{fieldErrors.firstName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastName">{t("lastName")}</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("lastNamePlaceholder")}
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={fieldErrors.lastName ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{fieldErrors.lastName && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{fieldErrors.lastName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">{t("phone")}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* Seletor de País */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-[140px] justify-between shrink-0"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{selectedCountry.flag}</span>
|
||||||
|
<span className="text-sm">
|
||||||
|
{selectedCountry.dialCode}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="w-[300px] max-h-[300px] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div className="p-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("searchCountry") || "Buscar país..."}
|
||||||
|
value={countrySearch}
|
||||||
|
onChange={(e) => setCountrySearch(e.target.value)}
|
||||||
|
className="mb-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filteredCountries.map((country) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={country.code}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCountry(country);
|
||||||
|
setCountrySearch("");
|
||||||
|
// Reformatar telefone com novo país
|
||||||
|
if (phone) {
|
||||||
|
const unformatted = unformatPhone(phone);
|
||||||
|
const reformatted = formatInternationalPhone(
|
||||||
|
unformatted,
|
||||||
|
country.dialCode,
|
||||||
|
country.code,
|
||||||
|
);
|
||||||
|
setPhone(reformatted);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 w-full">
|
||||||
|
<span>{country.flag}</span>
|
||||||
|
<span className="flex-1">{country.name}</span>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{country.dialCode}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Campo de Telefone */}
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="text"
|
||||||
|
placeholder={getPhonePlaceholder(selectedCountry.code)}
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => handlePhoneChange(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={20}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("phoneHint")}</p>
|
||||||
|
{fieldErrors.phone && (
|
||||||
|
<p className="text-sm text-destructive">{fieldErrors.phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Servers Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">{t("servers")}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("serversDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Search/Add */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("selectServer")}
|
||||||
|
value={serverSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setServerSearch(e.target.value);
|
||||||
|
setShowServerDropdown(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowServerDropdown(true)}
|
||||||
|
disabled={isLoading || serversLoading}
|
||||||
|
/>
|
||||||
|
{showServerDropdown && filteredServers.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{filteredServers.slice(0, 10).map((server) => (
|
||||||
|
<button
|
||||||
|
key={server.serverId}
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-accent rounded-sm text-sm"
|
||||||
|
onClick={() => addServer(server.serverName)}
|
||||||
|
>
|
||||||
|
{server.serverName}
|
||||||
|
{server.serverType && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
({server.serverType})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Servers */}
|
||||||
|
{selectedServers.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedServers.map((server, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-3 border rounded-md space-y-2 bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{server.serverName}</Badge>
|
||||||
|
{server.isMain && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
{t("mainCharacter")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!server.isMain && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setMainServer(index)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t("mainCharacter")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeServer(index)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`character-${index}`}>
|
||||||
|
{t("characterName")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`character-${index}`}
|
||||||
|
type="text"
|
||||||
|
placeholder={t("characterNamePlaceholder")}
|
||||||
|
value={server.characterName}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateServerCharacter(index, e.target.value)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={
|
||||||
|
fieldErrors[`server_${index}`]
|
||||||
|
? "border-destructive"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{fieldErrors[`server_${index}`] && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{fieldErrors[`server_${index}`]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fieldErrors.servers && (
|
||||||
|
<p className="text-sm text-destructive">{fieldErrors.servers}</p>
|
||||||
|
)}
|
||||||
|
{serversError && (
|
||||||
|
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||||
|
Erro ao carregar servidores: {serversError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading || serversLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? t("submitting") : t("submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbsProps {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="py-4">
|
||||||
|
<ol
|
||||||
|
itemScope
|
||||||
|
itemType="https://schema.org/BreadcrumbList"
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.href}
|
||||||
|
itemProp="itemListElement"
|
||||||
|
itemScope
|
||||||
|
itemType="https://schema.org/ListItem"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
itemProp="item"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span itemProp="name">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
<meta itemProp="position" content={String(index + 1)} />
|
||||||
|
{index < items.length - 1 && <ChevronRight className="w-4 h-4" />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Coins, DollarSign, MapPin, Package, User } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { Chat } from "@/lib/hooks/use-chat";
|
||||||
|
|
||||||
|
interface ChatHeaderProps {
|
||||||
|
chat: Chat;
|
||||||
|
currentUserId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
|
||||||
|
const listing = chat.listing_cache;
|
||||||
|
const otherUser =
|
||||||
|
chat.buyer_id === currentUserId ? chat.seller_cache : chat.buyer_cache;
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
const formatPrice = (
|
||||||
|
price: number | null | undefined,
|
||||||
|
type: "pix" | "tc" | "gp",
|
||||||
|
): string => {
|
||||||
|
if (price === null || price === undefined) return "N/A";
|
||||||
|
|
||||||
|
if (type === "pix") {
|
||||||
|
return new Intl.NumberFormat("pt-BR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "BRL",
|
||||||
|
}).format(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tc") {
|
||||||
|
return `${price.toLocaleString("pt-BR")} TC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "gp") {
|
||||||
|
const gold = price / 1000000;
|
||||||
|
if (gold >= 1) {
|
||||||
|
return `${gold.toFixed(2)}kk`;
|
||||||
|
}
|
||||||
|
return `${price.toLocaleString("pt-BR")} gp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return price.toLocaleString("pt-BR");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b bg-muted/30 p-4 space-y-3">
|
||||||
|
{/* Informações do Anúncio */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Imagem do item */}
|
||||||
|
<div className="relative w-16 h-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||||
|
{listing.item_image_url && !imageError ? (
|
||||||
|
<Image
|
||||||
|
src={listing.item_image_url}
|
||||||
|
alt={listing.item_name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Package className="h-8 w-8 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detalhes do anúncio */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-lg truncate">
|
||||||
|
{listing.item_name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
<span>{listing.server_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Package className="h-3 w-3" />
|
||||||
|
<span>Qtd: 1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preços */}
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
{listing.price_pix && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<DollarSign className="h-3 w-3" />
|
||||||
|
{formatPrice(listing.price_pix, "pix")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{listing.price_tibia_coins && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Coins className="h-3 w-3" />
|
||||||
|
{formatPrice(listing.price_tibia_coins, "tc")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{listing.price_gold_coins && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Coins className="h-3 w-3" />
|
||||||
|
{formatPrice(listing.price_gold_coins, "gp")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Informações do outro usuário */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t">
|
||||||
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Conversando com: </span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{otherUser.character_name || otherUser.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useChat } from "@/lib/hooks/use-chat";
|
||||||
|
import { useSession } from "@/lib/hooks/use-session";
|
||||||
|
import { ChatHeader } from "./ChatHeader";
|
||||||
|
import { MessageInput } from "./MessageInput";
|
||||||
|
import { MessageList } from "./MessageList";
|
||||||
|
|
||||||
|
interface ChatModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
listingId?: number | null;
|
||||||
|
chatId?: string | null;
|
||||||
|
initialMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
listingId,
|
||||||
|
chatId: initialChatId,
|
||||||
|
initialMessage,
|
||||||
|
}: ChatModalProps) {
|
||||||
|
const { user } = useSession();
|
||||||
|
const [chatId, setChatId] = useState<string | null>(initialChatId || null);
|
||||||
|
const [isCreatingChat, setIsCreatingChat] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
chat,
|
||||||
|
messages,
|
||||||
|
isLoading,
|
||||||
|
isSending,
|
||||||
|
sendMessage,
|
||||||
|
loadMoreMessages,
|
||||||
|
hasMore,
|
||||||
|
otherUserTyping,
|
||||||
|
} = useChat(chatId);
|
||||||
|
|
||||||
|
// Se chatId for fornecido diretamente, usar ele
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialChatId) {
|
||||||
|
setChatId(initialChatId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [initialChatId]);
|
||||||
|
|
||||||
|
// Criar ou buscar chat quando modal abrir (apenas se não tiver chatId)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !user || !listingId || initialChatId) {
|
||||||
|
if (!initialChatId && !listingId) {
|
||||||
|
setChatId(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createOrGetChat = async () => {
|
||||||
|
setIsCreatingChat(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/chats", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
listing_id: listingId,
|
||||||
|
buyer_message: initialMessage,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Erro ao criar chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setChatId(data.chat.chat_id);
|
||||||
|
|
||||||
|
// Se houver mensagem inicial, já foi enviada no backend
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao criar/buscar chat:", error);
|
||||||
|
} finally {
|
||||||
|
setIsCreatingChat(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createOrGetChat();
|
||||||
|
}, [open, user, listingId, initialMessage, initialChatId]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>Chat com vendedor</DialogTitle>
|
||||||
|
<DialogDescription>Conversa sobre o anúncio</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isCreatingChat || (isLoading && !chat) ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="space-y-4 w-full px-4">
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : chat ? (
|
||||||
|
<>
|
||||||
|
<ChatHeader chat={chat} currentUserId={user.userId} />
|
||||||
|
<MessageList
|
||||||
|
messages={messages}
|
||||||
|
currentUserId={user.userId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onLoadMore={loadMoreMessages}
|
||||||
|
hasMore={hasMore}
|
||||||
|
otherUserTyping={otherUserTyping}
|
||||||
|
/>
|
||||||
|
<MessageInput
|
||||||
|
onSend={sendMessage}
|
||||||
|
isSending={isSending}
|
||||||
|
disabled={!chat.is_active}
|
||||||
|
chatId={chatId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-center text-muted-foreground">
|
||||||
|
<p>Erro ao carregar chat</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ChatMessage } from "@/lib/hooks/use-chat";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
message: ChatMessage;
|
||||||
|
isOwn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: string): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleTimeString("pt-BR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
|
||||||
|
if (message.message_type === "system") {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center my-2">
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-3 py-1 rounded-full">
|
||||||
|
{message.content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full mb-2",
|
||||||
|
isOwn ? "justify-end" : "justify-start",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[70%] rounded-lg px-4 py-2",
|
||||||
|
isOwn
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 mt-1 text-xs",
|
||||||
|
isOwn ? "text-primary-foreground/70" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{formatTime(message.created_at)}</span>
|
||||||
|
{isOwn && message.read_at && <span className="opacity-70">✓✓</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, Send } from "lucide-react";
|
||||||
|
import { type KeyboardEvent, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useChatTyping } from "@/lib/hooks/use-chat";
|
||||||
|
import { useSocket } from "@/lib/hooks/use-socket";
|
||||||
|
|
||||||
|
interface MessageInputProps {
|
||||||
|
onSend: (content: string) => Promise<void>;
|
||||||
|
isSending: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
chatId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageInput({
|
||||||
|
onSend,
|
||||||
|
isSending,
|
||||||
|
disabled = false,
|
||||||
|
chatId,
|
||||||
|
}: MessageInputProps) {
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const { socket, isConnected } = useSocket();
|
||||||
|
const { handleTypingStart, handleTypingStop } = useChatTyping(
|
||||||
|
socket,
|
||||||
|
isConnected,
|
||||||
|
chatId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!content.trim() || isSending || disabled) return;
|
||||||
|
|
||||||
|
const messageContent = content.trim();
|
||||||
|
setContent("");
|
||||||
|
handleTypingStop();
|
||||||
|
await onSend(messageContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
setContent(value);
|
||||||
|
|
||||||
|
if (value.trim().length > 0) {
|
||||||
|
handleTypingStart();
|
||||||
|
} else {
|
||||||
|
handleTypingStop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t p-4">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Digite sua mensagem... (Enter para enviar, Shift+Enter para nova linha)"
|
||||||
|
className="min-h-[60px] max-h-[120px] resize-none"
|
||||||
|
disabled={isSending || disabled}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!content.trim() || isSending || disabled}
|
||||||
|
size="icon"
|
||||||
|
className="h-[60px] w-[60px] flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Máximo 1000 caracteres
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type { ChatMessage } from "@/lib/hooks/use-chat";
|
||||||
|
import { MessageBubble } from "./MessageBubble";
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
currentUserId: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
hasMore: boolean;
|
||||||
|
otherUserTyping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({
|
||||||
|
messages,
|
||||||
|
currentUserId,
|
||||||
|
isLoading,
|
||||||
|
onLoadMore,
|
||||||
|
hasMore,
|
||||||
|
otherUserTyping,
|
||||||
|
}: MessageListProps) {
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const prevMessagesLengthRef = useRef(0);
|
||||||
|
|
||||||
|
// Scroll para última mensagem quando novas mensagens chegarem
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > prevMessagesLengthRef.current) {
|
||||||
|
// Nova mensagem adicionada
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
prevMessagesLengthRef.current = messages.length;
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
// Scroll inicial para o final
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0 && !isLoading) {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
|
}
|
||||||
|
}, [isLoading, messages.length]);
|
||||||
|
|
||||||
|
// Detectar scroll no topo para carregar mais
|
||||||
|
const handleScroll = () => {
|
||||||
|
const container = messagesContainerRef.current;
|
||||||
|
if (!container || !onLoadMore || !hasMore || isLoading) return;
|
||||||
|
|
||||||
|
if (container.scrollTop === 0) {
|
||||||
|
const previousScrollHeight = container.scrollHeight;
|
||||||
|
onLoadMore();
|
||||||
|
|
||||||
|
// Ajustar scroll após carregar mais mensagens
|
||||||
|
setTimeout(() => {
|
||||||
|
const newScrollHeight = container.scrollHeight;
|
||||||
|
container.scrollTop = newScrollHeight - previousScrollHeight;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto p-4 space-y-1"
|
||||||
|
>
|
||||||
|
{isLoading && messages.length === 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-center text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">Nenhuma mensagem ainda</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
Envie a primeira mensagem para começar a conversa
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="text-center py-2">
|
||||||
|
<button
|
||||||
|
onClick={onLoadMore}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Carregando..." : "Carregar mensagens anteriores"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((message) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={message._id}
|
||||||
|
message={message}
|
||||||
|
isOwn={message.sender_id === currentUserId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{otherUserTyping && (
|
||||||
|
<div className="flex justify-start mb-2">
|
||||||
|
<div className="bg-muted rounded-lg px-4 py-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" />
|
||||||
|
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce delay-75" />
|
||||||
|
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce delay-150" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,31 @@
|
|||||||
"use client";
|
"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 confetti from "canvas-confetti";
|
||||||
import {
|
import {
|
||||||
Heart,
|
|
||||||
Coffee,
|
|
||||||
Pizza,
|
|
||||||
Loader2,
|
|
||||||
Copy,
|
|
||||||
Check,
|
Check,
|
||||||
Lock,
|
|
||||||
User,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
DollarSign,
|
Coffee,
|
||||||
|
Copy,
|
||||||
|
Heart,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
Pizza,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DonationTier, DonationResponse } from "@/types/donation";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useTranslations } from "next-intl";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type { DonationResponse, DonationTier } from "@/types/donation";
|
||||||
|
|
||||||
interface DonationCardProps extends DonationTier {}
|
interface DonationCardProps extends DonationTier {}
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ export function DonationCard({
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
|
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
|
||||||
const [paymentConfirmed, setPaymentConfirmed] = useState(false);
|
const [_paymentConfirmed, setPaymentConfirmed] = useState(false);
|
||||||
const [showThankYouModal, setShowThankYouModal] = useState(false);
|
const [showThankYouModal, setShowThankYouModal] = useState(false);
|
||||||
|
|
||||||
const t = useTranslations("donation");
|
const t = useTranslations("donation");
|
||||||
@@ -94,7 +93,7 @@ export function DonationCard({
|
|||||||
|
|
||||||
const updateTimer = () => {
|
const updateTimer = () => {
|
||||||
try {
|
try {
|
||||||
const expires = new Date(pixData!.expiresAt!);
|
const expires = new Date(pixData?.expiresAt!);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = expires.getTime() - now.getTime();
|
const diff = expires.getTime() - now.getTime();
|
||||||
|
|
||||||
@@ -118,7 +117,7 @@ export function DonationCard({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/donations/status?pixId=${pixData.pixId}`
|
`/api/donations/status?pixId=${pixData.pixId}`,
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -346,7 +345,7 @@ export function DonationCard({
|
|||||||
const isValidCustomAmount = () => {
|
const isValidCustomAmount = () => {
|
||||||
const amount = parseFloat(customAmount);
|
const amount = parseFloat(customAmount);
|
||||||
return (
|
return (
|
||||||
!isNaN(amount) &&
|
!Number.isNaN(amount) &&
|
||||||
amount >= customProduct.minAmount &&
|
amount >= customProduct.minAmount &&
|
||||||
amount <= customProduct.maxAmount
|
amount <= customProduct.maxAmount
|
||||||
);
|
);
|
||||||
@@ -363,12 +362,12 @@ export function DonationCard({
|
|||||||
// Garante apenas um separador decimal
|
// Garante apenas um separador decimal
|
||||||
const parts = normalized.split(".");
|
const parts = normalized.split(".");
|
||||||
if (parts.length > 2) {
|
if (parts.length > 2) {
|
||||||
return parts[0] + "." + parts.slice(1).join("");
|
return `${parts[0]}.${parts.slice(1).join("")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limita 2 casas decimais
|
// Limita 2 casas decimais
|
||||||
if (parts[1]?.length > 2) {
|
if (parts[1]?.length > 2) {
|
||||||
return parts[0] + "." + parts[1].substring(0, 2);
|
return `${parts[0]}.${parts[1].substring(0, 2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -396,12 +395,12 @@ export function DonationCard({
|
|||||||
setError("Digite um valor");
|
setError("Digite um valor");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const amount = parseFloat(customAmount);
|
const _amount = parseFloat(customAmount);
|
||||||
if (!isValidCustomAmount()) {
|
if (!isValidCustomAmount()) {
|
||||||
setError(
|
setError(
|
||||||
`Valor deve estar entre R$ ${customProduct.minAmount.toFixed(
|
`Valor deve estar entre R$ ${customProduct.minAmount.toFixed(
|
||||||
2
|
2,
|
||||||
)} e R$ ${customProduct.maxAmount.toFixed(2)}`
|
)} e R$ ${customProduct.maxAmount.toFixed(2)}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -463,10 +462,10 @@ export function DonationCard({
|
|||||||
<p className="text-xs text-red-500">
|
<p className="text-xs text-red-500">
|
||||||
{parseFloat(customAmount) < customProduct.minAmount
|
{parseFloat(customAmount) < customProduct.minAmount
|
||||||
? `${t(
|
? `${t(
|
||||||
"form.minAmount"
|
"form.minAmount",
|
||||||
)} ${customProduct.minAmount.toFixed(2)}`
|
)} ${customProduct.minAmount.toFixed(2)}`
|
||||||
: `${t(
|
: `${t(
|
||||||
"form.maxAmount"
|
"form.maxAmount",
|
||||||
)} ${customProduct.maxAmount.toFixed(2)}`}
|
)} ${customProduct.maxAmount.toFixed(2)}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -485,7 +484,7 @@ export function DonationCard({
|
|||||||
</>
|
</>
|
||||||
) : customAmount ? (
|
) : customAmount ? (
|
||||||
`${t("form.donate")} ${parseFloat(
|
`${t("form.donate")} ${parseFloat(
|
||||||
customAmount || "0"
|
customAmount || "0",
|
||||||
).toFixed(2)}`
|
).toFixed(2)}`
|
||||||
) : (
|
) : (
|
||||||
t("form.chooseAmount")
|
t("form.chooseAmount")
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Coffee, Heart } from "lucide-react";
|
||||||
import { Heart, Coffee } from "lucide-react";
|
|
||||||
import { BlurFade } from "@/components/ui/blur-fade";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { BlurFade } from "@/components/ui/blur-fade";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function DonationFooterCard() {
|
export function DonationFooterCard() {
|
||||||
const t = useTranslations("donation");
|
const t = useTranslations("donation");
|
||||||
|
|||||||
+565
-191
@@ -1,48 +1,226 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { TibiaItem } from "@/types/item";
|
|
||||||
import { MagicCard } from "@/components/ui/magic-card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { PriceDisplay } from "@/components/ui/price-display";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
ArrowDown,
|
||||||
TrendingDown,
|
ArrowUp,
|
||||||
Sword,
|
Boxes,
|
||||||
Shield,
|
|
||||||
Weight,
|
|
||||||
Heart,
|
|
||||||
Star,
|
|
||||||
Clock,
|
Clock,
|
||||||
Coins,
|
Coins,
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
Minus,
|
|
||||||
Zap,
|
|
||||||
Flame,
|
|
||||||
Snowflake,
|
|
||||||
Zap as Energy,
|
|
||||||
Skull,
|
|
||||||
Cross,
|
Cross,
|
||||||
|
Zap as Energy,
|
||||||
|
Flame,
|
||||||
|
FlaskConical,
|
||||||
|
Gem,
|
||||||
|
Heart,
|
||||||
|
Leaf,
|
||||||
|
Minus,
|
||||||
|
ScrollText,
|
||||||
|
Shield,
|
||||||
|
Skull,
|
||||||
|
Snowflake,
|
||||||
|
Star,
|
||||||
|
Sword,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
Weight,
|
||||||
|
Wrench,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import {
|
import { useEffect, useRef, useState } from "react";
|
||||||
getRarityColor,
|
import { Badge } from "@/components/ui/badge";
|
||||||
getRarityBgColor,
|
import { Button } from "@/components/ui/button";
|
||||||
getRarityGlow,
|
import { MagicCard } from "@/components/ui/magic-card";
|
||||||
} from "@/lib/currency-utils";
|
import { PriceDisplay } from "@/components/ui/price-display";
|
||||||
|
import { formatNumber } from "@/lib/utils/formatters";
|
||||||
|
import type { TibiaItem } from "@/types/item";
|
||||||
|
|
||||||
interface ItemCardProps {
|
interface ItemCardProps {
|
||||||
item: TibiaItem;
|
item: TibiaItem;
|
||||||
|
mode?: "market" | "catalog";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemCard({ item }: ItemCardProps) {
|
export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||||
const t = useTranslations("home.item");
|
const t = useTranslations("home.item");
|
||||||
|
const locale = useLocale();
|
||||||
const [isFavorited, setIsFavorited] = useState(false);
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [_isHovered, setIsHovered] = useState(false);
|
||||||
|
const formatVocation = (v: string | undefined): string | undefined => {
|
||||||
|
if (!v) return undefined;
|
||||||
|
let out = String(v);
|
||||||
|
// Inserir espaço quando 'and' vier colado em palavras
|
||||||
|
out = out.replace(/([A-Za-z])and([A-Za-z])/g, "$1 and $2");
|
||||||
|
// Normalizar vírgulas e conectivos
|
||||||
|
out = out.replace(/,/g, ", ");
|
||||||
|
out = out.replace(/\s*and\s*/gi, " and ");
|
||||||
|
// Colapsar espaços múltiplos
|
||||||
|
out = out.replace(/\s+/g, " ").trim();
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
const rarityColors = {
|
const ScrollRow = ({
|
||||||
|
children,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) => {
|
||||||
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [_canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
|
const [_canScrollRight, setCanScrollRight] = useState(false);
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const autoScrollRef = useRef<number | null>(null);
|
||||||
|
const drag = useRef<{ active: boolean; startX: number; startLeft: number }>(
|
||||||
|
{ active: false, startX: 0, startLeft: 0 },
|
||||||
|
);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const updateButtons = () => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const base = contentRef.current?.offsetWidth || 0;
|
||||||
|
const hasOverflow = el.scrollWidth > el.clientWidth + 2 && base > 0;
|
||||||
|
// Em loop infinito, ambos os lados ficam disponíveis quando há overflow
|
||||||
|
setCanScrollLeft(hasOverflow);
|
||||||
|
setCanScrollRight(hasOverflow);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Inject global CSS once to hide scrollbars cross-browser
|
||||||
|
const styleId = "no-scrollbar-styles";
|
||||||
|
if (!document.getElementById(styleId)) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = styleId;
|
||||||
|
style.textContent = `
|
||||||
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; height: 0; width: 0; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateButtons();
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const handler = () => updateButtons();
|
||||||
|
el.addEventListener("scroll", handler);
|
||||||
|
const ro = new ResizeObserver(() => updateButtons());
|
||||||
|
ro.observe(el);
|
||||||
|
const onEnter = () => setIsHovering(true);
|
||||||
|
const onLeave = () => setIsHovering(false);
|
||||||
|
el.addEventListener("mouseenter", onEnter);
|
||||||
|
el.addEventListener("mouseleave", onLeave);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("scroll", handler);
|
||||||
|
el.removeEventListener("mouseenter", onEnter);
|
||||||
|
el.removeEventListener("mouseleave", onLeave);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [updateButtons]);
|
||||||
|
|
||||||
|
const _scrollBy = (dx: number) => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const base = contentRef.current?.offsetWidth || 0;
|
||||||
|
if (base > 0) {
|
||||||
|
let next = el.scrollLeft + dx;
|
||||||
|
if (next < 0) next += base;
|
||||||
|
if (next >= base) next -= base;
|
||||||
|
el.scrollTo({ left: next, behavior: "smooth" });
|
||||||
|
} else {
|
||||||
|
el.scrollBy({ left: dx, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-scroll when not hovering and there's overflow
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const base = contentRef.current?.offsetWidth || 0;
|
||||||
|
const hasOverflow = el.scrollWidth > el.clientWidth + 2 && base > 0;
|
||||||
|
if (!isHovering && hasOverflow) {
|
||||||
|
// velocidade ajustada para ~36px/s @60fps
|
||||||
|
el.scrollLeft += 0.6;
|
||||||
|
if (base > 0 && el.scrollLeft >= base) {
|
||||||
|
// wrap sem salto perceptível
|
||||||
|
el.scrollLeft -= base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
autoScrollRef.current = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
autoScrollRef.current = window.requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (autoScrollRef.current) cancelAnimationFrame(autoScrollRef.current);
|
||||||
|
autoScrollRef.current = null;
|
||||||
|
};
|
||||||
|
}, [isHovering]);
|
||||||
|
|
||||||
|
// Drag to scroll (pointer events)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return; // only left click/touch
|
||||||
|
drag.current.active = true;
|
||||||
|
drag.current.startX = e.clientX;
|
||||||
|
drag.current.startLeft = el.scrollLeft;
|
||||||
|
setIsDragging(true);
|
||||||
|
el.setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (!drag.current.active) return;
|
||||||
|
const dx = e.clientX - drag.current.startX;
|
||||||
|
const base = contentRef.current?.offsetWidth || 0;
|
||||||
|
let next = drag.current.startLeft - dx;
|
||||||
|
if (base > 0) {
|
||||||
|
if (next < 0) next += base;
|
||||||
|
if (next >= base) next -= base;
|
||||||
|
}
|
||||||
|
el.scrollLeft = next;
|
||||||
|
};
|
||||||
|
const onPointerUp = (e: PointerEvent) => {
|
||||||
|
if (!drag.current.active) return;
|
||||||
|
drag.current.active = false;
|
||||||
|
setIsDragging(false);
|
||||||
|
try {
|
||||||
|
el.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener("pointerdown", onPointerDown);
|
||||||
|
el.addEventListener("pointermove", onPointerMove);
|
||||||
|
el.addEventListener("pointerup", onPointerUp);
|
||||||
|
el.addEventListener("pointercancel", onPointerUp);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
el.removeEventListener("pointermove", onPointerMove);
|
||||||
|
el.removeEventListener("pointerup", onPointerUp);
|
||||||
|
el.removeEventListener("pointercancel", onPointerUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* botões removidos conforme solicitado */}
|
||||||
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
|
className={`overflow-x-auto no-scrollbar whitespace-nowrap pr-6 pl-6 select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||||
|
style={{ scrollBehavior: "smooth" }}
|
||||||
|
>
|
||||||
|
<div ref={contentRef} className="inline-flex items-center gap-2">
|
||||||
|
{children}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [_showAllDroppers, _setShowAllDroppers] = useState(false);
|
||||||
|
|
||||||
|
const _rarityColors = {
|
||||||
common: "bg-gray-500",
|
common: "bg-gray-500",
|
||||||
uncommon: "bg-green-500",
|
uncommon: "bg-green-500",
|
||||||
rare: "bg-blue-500",
|
rare: "bg-blue-500",
|
||||||
@@ -57,28 +235,44 @@ export function ItemCard({ item }: ItemCardProps) {
|
|||||||
legs: Shield,
|
legs: Shield,
|
||||||
boots: Shield,
|
boots: Shield,
|
||||||
shield: Shield,
|
shield: Shield,
|
||||||
amulet: "💎",
|
amulet: Gem,
|
||||||
ring: "💍",
|
ring: Gem,
|
||||||
potion: "🧪",
|
potion: FlaskConical,
|
||||||
rune: "📜",
|
rune: ScrollText,
|
||||||
tool: "🔧",
|
tool: Wrench,
|
||||||
ammunition: "🏹",
|
ammunition: Boxes,
|
||||||
other: "📦",
|
other: Boxes,
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
const CategoryIcon = categoryIcons[item.category];
|
const CategoryIcon = categoryIcons[item.category];
|
||||||
|
|
||||||
// Calcular diferença percentual entre compra e venda
|
// Calcular diferença percentual entre compra e venda (somente quando houver preço)
|
||||||
const priceDifference =
|
const hasPrice =
|
||||||
((item.price.buy - item.price.sell) / item.price.sell) * 100;
|
item &&
|
||||||
const isHotDeal = priceDifference > 20; // Hot deal se diferença > 20%
|
(item as any).price &&
|
||||||
|
typeof (item as any).price.buy === "number" &&
|
||||||
|
typeof (item as any).price.sell === "number" &&
|
||||||
|
(item as any).price.sell !== 0;
|
||||||
|
const priceDifference = hasPrice
|
||||||
|
? (((item as any).price.buy - (item as any).price.sell) /
|
||||||
|
(item as any).price.sell) *
|
||||||
|
100
|
||||||
|
: 0;
|
||||||
|
const isHotDeal = hasPrice && priceDifference > 20; // Hot deal se diferença > 20%
|
||||||
|
|
||||||
// Determinar tendência de preço
|
// Determinar tendência de preço
|
||||||
const getPriceTrend = () => {
|
const getPriceTrend = () => {
|
||||||
if (item.priceHistory.highest > item.priceHistory.average * 1.1)
|
const ph = (item as any).priceHistory;
|
||||||
return "up";
|
if (
|
||||||
if (item.priceHistory.lowest < item.priceHistory.average * 0.9)
|
!ph ||
|
||||||
return "down";
|
typeof ph.highest !== "number" ||
|
||||||
|
typeof ph.lowest !== "number" ||
|
||||||
|
typeof ph.average !== "number"
|
||||||
|
) {
|
||||||
|
return "stable";
|
||||||
|
}
|
||||||
|
if (ph.highest > ph.average * 1.1) return "up";
|
||||||
|
if (ph.lowest < ph.average * 0.9) return "down";
|
||||||
return "stable";
|
return "stable";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,31 +283,18 @@ export function ItemCard({ item }: ItemCardProps) {
|
|||||||
trend === "up"
|
trend === "up"
|
||||||
? "text-green-500"
|
? "text-green-500"
|
||||||
: trend === "down"
|
: trend === "down"
|
||||||
? "text-red-500"
|
? "text-red-500"
|
||||||
: "text-gray-500";
|
: "text-gray-500";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group relative h-full"
|
className="group relative"
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<MagicCard className="p-0 overflow-hidden h-full flex flex-col rounded-xl border border-border/50 hover:border-primary/30 transition-all duration-300 hover:scale-[1.02] hover:shadow-xl">
|
<MagicCard className="p-0 overflow-hidden rounded-xl border border-border/50 hover:border-primary/30 transition-all duration-300 hover:scale-[1.02] hover:shadow-xl">
|
||||||
{/* Header com ícone e badge de raridade */}
|
{/* Header com ícone e badge de raridade */}
|
||||||
<div className="relative aspect-square bg-linear-to-br from-background to-muted p-6">
|
<div className="relative aspect-square bg-linear-to-br from-background to-muted p-6">
|
||||||
{/* Badge de raridade com glow */}
|
|
||||||
<Badge
|
|
||||||
className={`${
|
|
||||||
rarityColors[item.rarity]
|
|
||||||
} text-white absolute top-3 left-3 z-20 shadow-lg ${getRarityGlow(
|
|
||||||
item.rarity
|
|
||||||
)} ${item.rarity === "legendary" ? "animate-pulse" : ""} ${
|
|
||||||
item.rarity === "epic" ? "animate-pulse" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.rarity}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{/* Botão de favorito */}
|
{/* Botão de favorito */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -130,22 +311,33 @@ export function ItemCard({ item }: ItemCardProps) {
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Hot Deal Badge */}
|
{/* Hot Deal Badge (apenas em modo market) */}
|
||||||
{isHotDeal && (
|
{mode === "market" && isHotDeal && (
|
||||||
<Badge className="absolute top-3 left-1/2 transform -translate-x-1/2 z-20 bg-red-500 text-white shadow-lg animate-pulse">
|
<Badge className="absolute top-3 left-1/2 transform -translate-x-1/2 z-20 bg-red-500 text-white shadow-lg animate-pulse flex items-center gap-1">
|
||||||
🔥 Hot Deal
|
<Flame className="h-3 w-3" />
|
||||||
|
Hot Deal
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ícone do item */}
|
{/* Imagem real do item (se disponível) ou ícone por categoria */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
|
{item.image ? (
|
||||||
{typeof CategoryIcon === "string" ? (
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
CategoryIcon
|
<img
|
||||||
) : (
|
src={item.image}
|
||||||
<CategoryIcon className="w-10 h-10" />
|
alt={item.name}
|
||||||
)}
|
className="w-20 h-20 object-contain drop-shadow-sm"
|
||||||
</div>
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
|
||||||
|
{typeof CategoryIcon === "string" ? null : (
|
||||||
|
<CategoryIcon className="w-10 h-10" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,6 +354,27 @@ export function ItemCard({ item }: ItemCardProps) {
|
|||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
<span>Atualizado há 5 min</span>
|
<span>Atualizado há 5 min</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Meta: ID e Categoria */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{(() => {
|
||||||
|
const catPt = (item as any).categoryPt as string | undefined;
|
||||||
|
const catEn = (item as any).category as string | undefined;
|
||||||
|
const catRaw =
|
||||||
|
(catPt && String(catPt)) || (catEn && String(catEn)) || "";
|
||||||
|
const display = catRaw
|
||||||
|
? catRaw.charAt(0).toUpperCase() + catRaw.slice(1)
|
||||||
|
: "";
|
||||||
|
return display ? (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-muted/60 text-foreground/80">
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
{typeof item.id === "string" && item.id && (
|
||||||
|
<span className="opacity-80">ID: {item.id}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Estatísticas agrupadas */}
|
{/* Estatísticas agrupadas */}
|
||||||
@@ -171,24 +384,36 @@ export function ItemCard({ item }: ItemCardProps) {
|
|||||||
{item.stats.attack && (
|
{item.stats.attack && (
|
||||||
<div className="flex items-center gap-1.5 text-sm">
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
<Sword className="h-4 w-4 text-primary" />
|
<Sword className="h-4 w-4 text-primary" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">{item.stats.attack} ATK</span>
|
||||||
⚔️ {item.stats.attack} ATK
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.stats.defense && (
|
{item.stats.defense && (
|
||||||
<div className="flex items-center gap-1.5 text-sm">
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
<Shield className="h-4 w-4 text-primary" />
|
<Shield className="h-4 w-4 text-primary" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
🛡️ {item.stats.defense} DEF
|
{item.stats.defense} DEF
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.stats.armor && (
|
{item.stats.armor && (
|
||||||
<div className="flex items-center gap-1.5 text-sm">
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
<Shield className="h-4 w-4 text-primary" />
|
<Shield className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-medium">{item.stats.armor} ARM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(item as any).volume && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<Boxes className="h-4 w-4 text-primary" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
🛡️ {item.stats.armor} ARM
|
Volume {(item as any).volume}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(item as any).bonus_de_skill && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<Zap className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-medium">
|
||||||
|
Skill +{(item as any).bonus_de_skill}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -196,157 +421,306 @@ export function ItemCard({ item }: ItemCardProps) {
|
|||||||
|
|
||||||
{/* Proteções elementais */}
|
{/* Proteções elementais */}
|
||||||
{item.protections && (
|
{item.protections && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<ScrollRow ariaLabel="protections">
|
||||||
{item.protections.fire && (
|
{item.protections.fire && (
|
||||||
<div className="flex items-center gap-1 text-xs bg-red-500/10 text-red-600 px-2 py-1 rounded">
|
<div className="flex items-center gap-1 text-xs bg-red-500/10 text-red-600 px-2 py-1 rounded">
|
||||||
<Flame className="h-3 w-3" />
|
<Flame className="h-3 w-3" />
|
||||||
<span>🔥 Fire +{item.protections.fire}%</span>
|
<span>Fire +{item.protections.fire}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.protections.ice && (
|
{item.protections.ice && (
|
||||||
<div className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-600 px-2 py-1 rounded">
|
<div className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-600 px-2 py-1 rounded">
|
||||||
<Snowflake className="h-3 w-3" />
|
<Snowflake className="h-3 w-3" />
|
||||||
<span>❄️ Ice +{item.protections.ice}%</span>
|
<span>Ice +{item.protections.ice}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.protections.energy && (
|
{item.protections.energy && (
|
||||||
<div className="flex items-center gap-1 text-xs bg-yellow-500/10 text-yellow-600 px-2 py-1 rounded">
|
<div className="flex items-center gap-1 text-xs bg-yellow-500/10 text-yellow-600 px-2 py-1 rounded">
|
||||||
<Energy className="h-3 w-3" />
|
<Energy className="h-3 w-3" />
|
||||||
<span>⚡ Energy +{item.protections.energy}%</span>
|
<span>Energy +{item.protections.energy}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ScrollRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Requisitos */}
|
{/* Requisitos */}
|
||||||
{item.requirements && (
|
{item.requirements && (
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap gap-2">
|
||||||
{item.requirements.level && (
|
{typeof (item as any).requirements?.level === "number" && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-1 rounded">
|
||||||
📊 Level {item.requirements.level}+
|
<Star className="h-3 w-3" />
|
||||||
|
<span>Lvl {(item as any).requirements.level}+</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(item as any).requirements?.skill && (
|
||||||
|
<div className="flex items-center gap-1 text-xs bg-indigo-500/10 text-indigo-600 px-2 py-1 rounded">
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
<span>{(item as any).requirements.skill}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.requirements.vocation && (
|
{item.requirements.vocation && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
👤 Best for {item.requirements.vocation}
|
Best for {formatVocation(item.requirements.vocation)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(item as any).protecao &&
|
||||||
|
(() => {
|
||||||
|
const raw = String((item as any).protecao || "");
|
||||||
|
const tokens = raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((s) => !/^nenhum(a)?\.?$/i.test(s.trim()));
|
||||||
|
if (tokens.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ScrollRow ariaLabel="elemental protections">
|
||||||
|
{tokens.map((raw: string, idx: number) => {
|
||||||
|
const token = raw.trim();
|
||||||
|
const match = token.match(
|
||||||
|
/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/,
|
||||||
|
);
|
||||||
|
const type = (match?.[1] || token).trim().toLowerCase();
|
||||||
|
const value = (match?.[2] || "").trim();
|
||||||
|
let Icon: any = Shield;
|
||||||
|
let chipBg = "bg-amber-500/10";
|
||||||
|
let chipText = "text-amber-700";
|
||||||
|
switch (type) {
|
||||||
|
case "fire":
|
||||||
|
case "fogo":
|
||||||
|
Icon = Flame;
|
||||||
|
chipBg = "bg-red-500/10";
|
||||||
|
chipText = "text-red-600";
|
||||||
|
break;
|
||||||
|
case "ice":
|
||||||
|
case "gelo":
|
||||||
|
Icon = Snowflake;
|
||||||
|
chipBg = "bg-blue-500/10";
|
||||||
|
chipText = "text-blue-600";
|
||||||
|
break;
|
||||||
|
case "energy":
|
||||||
|
case "energia":
|
||||||
|
Icon = Energy;
|
||||||
|
chipBg = "bg-yellow-500/10";
|
||||||
|
chipText = "text-yellow-600";
|
||||||
|
break;
|
||||||
|
case "earth":
|
||||||
|
case "terra":
|
||||||
|
Icon = Leaf;
|
||||||
|
chipBg = "bg-green-500/10";
|
||||||
|
chipText = "text-green-600";
|
||||||
|
break;
|
||||||
|
case "death":
|
||||||
|
case "morte":
|
||||||
|
Icon = Skull;
|
||||||
|
chipBg = "bg-zinc-500/10";
|
||||||
|
chipText = "text-zinc-600";
|
||||||
|
break;
|
||||||
|
case "holy":
|
||||||
|
case "sagrado":
|
||||||
|
Icon = Cross;
|
||||||
|
chipBg = "bg-amber-500/10";
|
||||||
|
chipText = "text-amber-700";
|
||||||
|
break;
|
||||||
|
case "physical":
|
||||||
|
case "fisico":
|
||||||
|
Icon = Shield;
|
||||||
|
chipBg = "bg-slate-500/10";
|
||||||
|
chipText = "text-slate-600";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Icon = Shield;
|
||||||
|
chipBg = "bg-amber-500/10";
|
||||||
|
chipText = "text-amber-700";
|
||||||
|
}
|
||||||
|
const prettyType =
|
||||||
|
type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
const label = `${prettyType.replace("fisico", "Físico")}${value ? ` ${value}` : ""}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${type}-${idx}`}
|
||||||
|
className={`flex items-center gap-1 text-xs ${chipBg} ${chipText} px-2 py-1 rounded`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
<span className="whitespace-nowrap">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollRow>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{item.stats.weight && (
|
{item.stats.weight && (
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Weight className="h-4 w-4" />
|
<Weight className="h-4 w-4" />
|
||||||
<span>⚖️ {item.stats.weight} oz</span>
|
<span>{item.stats.weight} oz</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(item as any).stats?.slots && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Boxes className="h-3 w-3" />
|
||||||
|
<span>Slots {(item as any).stats?.slots}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Divisor visual */}
|
{(item as any).dropa_de &&
|
||||||
<div className="border-t border-border/50" />
|
(() => {
|
||||||
|
const raw = String((item as any).dropa_de || "");
|
||||||
{/* Preços com ícones e formatação melhorada */}
|
const normalized = raw.trim();
|
||||||
<div className="space-y-3">
|
// Casos especiais de "nenhum/ninguém"
|
||||||
<div className="grid grid-cols-2 gap-4">
|
const none = /^(nenhum|nenhuma|ningu[eé]m)\.?$/i.test(normalized);
|
||||||
<div className="space-y-1">
|
if (none) {
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
return (
|
||||||
<Coins className="h-3 w-3" />
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span>{t("buyPrice")}</span>
|
<Skull className="h-3 w-3" />
|
||||||
|
<span>Dropa de: Ninguém</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const droppers = normalized
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground mb-1">
|
||||||
|
<Skull className="h-3 w-3" />
|
||||||
|
<span>Dropa de:</span>
|
||||||
|
</div>
|
||||||
|
<ScrollRow ariaLabel="droppers">
|
||||||
|
{droppers.map((name, idx) => (
|
||||||
|
<span
|
||||||
|
key={`dropper-${idx}-${name}`}
|
||||||
|
className="px-2 py-0.5 rounded bg-muted/60 text-foreground/80 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</ScrollRow>
|
||||||
</div>
|
</div>
|
||||||
<PriceDisplay
|
);
|
||||||
value={item.price.buy}
|
})()}
|
||||||
className="text-green-600 dark:text-green-400"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<Coins className="h-3 w-3" />
|
|
||||||
<span>{t("sellPrice")}</span>
|
|
||||||
</div>
|
|
||||||
<PriceDisplay
|
|
||||||
value={item.price.sell}
|
|
||||||
className="text-red-600 dark:text-red-400"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Diferença percentual */}
|
{mode === "market" && item.price && (
|
||||||
<div className="text-center">
|
<>
|
||||||
<span
|
{/* Divisor visual */}
|
||||||
className={`text-sm font-medium ${
|
<div className="border-t border-border/50" />
|
||||||
priceDifference > 0 ? "text-green-600" : "text-red-600"
|
|
||||||
}`}
|
{/* Preços com ícones e formatação melhorada */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Coins className="h-3 w-3" />
|
||||||
|
<span>{t("buyPrice")}</span>
|
||||||
|
</div>
|
||||||
|
<PriceDisplay
|
||||||
|
value={item.price.buy}
|
||||||
|
className="text-green-600 dark:text-green-400"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Coins className="h-3 w-3" />
|
||||||
|
<span>{t("sellPrice")}</span>
|
||||||
|
</div>
|
||||||
|
<PriceDisplay
|
||||||
|
value={item.price.sell}
|
||||||
|
className="text-red-600 dark:text-red-400"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diferença percentual */}
|
||||||
|
<div className="text-center">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
priceDifference > 0 ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{priceDifference > 0 ? "+" : ""}
|
||||||
|
{priceDifference.toFixed(1)}% diferença
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "market" && item.priceHistory && (
|
||||||
|
<>
|
||||||
|
{/* Divisor visual */}
|
||||||
|
<div className="border-t border-border/50" />
|
||||||
|
|
||||||
|
{/* Histórico de preço com sparkline */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("priceHistory")}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 text-xs ${trendColor}`}
|
||||||
|
>
|
||||||
|
<TrendIcon className="h-3 w-3" />
|
||||||
|
<span className="capitalize">{trend}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<div className="flex items-center justify-center gap-1 text-green-600">
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
<span className="font-medium">{t("highest")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
{formatNumber(item.priceHistory.highest, locale)} gp
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<div className="flex items-center justify-center gap-1 text-red-600">
|
||||||
|
<TrendingDown className="h-3 w-3" />
|
||||||
|
<span className="font-medium">{t("lowest")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
{formatNumber(item.priceHistory.lowest, locale)} gp
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<div className="flex items-center justify-center gap-1 text-muted-foreground">
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
<span className="font-medium">{t("average")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
{formatNumber(item.priceHistory.average, locale)} gp
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "market" && (
|
||||||
|
<div className="pt-4 space-y-2">
|
||||||
|
<Button
|
||||||
|
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
{priceDifference > 0 ? "+" : ""}
|
{t("viewDetails")}
|
||||||
{priceDifference.toFixed(1)}% diferença
|
</Button>
|
||||||
</span>
|
<Button
|
||||||
|
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("buyNow")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Divisor visual */}
|
|
||||||
<div className="border-t border-border/50" />
|
|
||||||
|
|
||||||
{/* Histórico de preço com sparkline */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
{t("priceHistory")}
|
|
||||||
</span>
|
|
||||||
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
|
|
||||||
<TrendIcon className="h-3 w-3" />
|
|
||||||
<span className="capitalize">{trend}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
||||||
<div className="text-center space-y-1">
|
|
||||||
<div className="flex items-center justify-center gap-1 text-green-600">
|
|
||||||
<TrendingUp className="h-3 w-3" />
|
|
||||||
<span className="font-medium">{t("highest")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
🪙 {item.priceHistory.highest.toLocaleString()} gp
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center space-y-1">
|
|
||||||
<div className="flex items-center justify-center gap-1 text-red-600">
|
|
||||||
<TrendingDown className="h-3 w-3" />
|
|
||||||
<span className="font-medium">{t("lowest")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
🪙 {item.priceHistory.lowest.toLocaleString()} gp
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center space-y-1">
|
|
||||||
<div className="flex items-center justify-center gap-1 text-muted-foreground">
|
|
||||||
<Minus className="h-3 w-3" />
|
|
||||||
<span className="font-medium">{t("average")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
🪙 {item.priceHistory.average.toLocaleString()} gp
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Botões de ação */}
|
|
||||||
<div className="pt-4 space-y-2">
|
|
||||||
<Button
|
|
||||||
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{t("viewDetails")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t("buyNow")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</MagicCard>
|
</MagicCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
import { useLocale } from "next-intl";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ code: "pt", name: "Português", flag: "🇧🇷" },
|
{ code: "pt", name: "Português", flag: "🇧🇷" },
|
||||||
@@ -22,6 +22,12 @@ export function LanguageSwitcher() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const t = useTranslations("header");
|
const t = useTranslations("header");
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Evitar hydration mismatch com Radix UI
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLanguageChange = (newLocale: string) => {
|
const handleLanguageChange = (newLocale: string) => {
|
||||||
// Remove the current locale from the pathname
|
// Remove the current locale from the pathname
|
||||||
@@ -30,7 +36,17 @@ export function LanguageSwitcher() {
|
|||||||
router.push(`/${newLocale}${pathWithoutLocale}`);
|
router.push(`/${newLocale}${pathWithoutLocale}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentLanguage = languages.find((lang) => lang.code === locale);
|
const _currentLanguage = languages.find((lang) => lang.code === locale);
|
||||||
|
|
||||||
|
// Renderizar placeholder durante SSR para evitar mismatch
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="icon" disabled>
|
||||||
|
<Globe className="h-[1.2rem] w-[1.2rem]" />
|
||||||
|
<span className="sr-only">{t("language")}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -55,8 +71,3 @@ export function LanguageSwitcher() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Filter, Search, SortAsc, SortDesc, X } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { useState } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Button } from "@/components/ui/button";
|
||||||
Search,
|
|
||||||
Filter,
|
|
||||||
SortAsc,
|
|
||||||
SortDesc,
|
|
||||||
X,
|
|
||||||
Star,
|
|
||||||
TrendingUp,
|
|
||||||
Clock,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -23,18 +13,22 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
interface MarketplaceFiltersProps {
|
interface MarketplaceFiltersProps {
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
onFilter: (filters: FilterState) => void;
|
onFilter: (filters: FilterState) => void;
|
||||||
onSort: (sortBy: string) => void;
|
onSort: (sortBy: string) => void;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
|
showRarity?: boolean;
|
||||||
|
categoryOptionsOverride?: { value: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterState {
|
interface FilterState {
|
||||||
rarity: string[];
|
rarity: string[];
|
||||||
category: string[];
|
category: string[];
|
||||||
priceRange: { min: number; max: number };
|
priceRange: { min: number; max: number };
|
||||||
|
vocation?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarketplaceFilters({
|
export function MarketplaceFilters({
|
||||||
@@ -42,13 +36,16 @@ export function MarketplaceFilters({
|
|||||||
onFilter,
|
onFilter,
|
||||||
onSort,
|
onSort,
|
||||||
totalItems,
|
totalItems,
|
||||||
|
showRarity = true,
|
||||||
|
categoryOptionsOverride,
|
||||||
}: MarketplaceFiltersProps) {
|
}: MarketplaceFiltersProps) {
|
||||||
const t = useTranslations("home");
|
const _t = useTranslations("home");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeFilters, setActiveFilters] = useState<FilterState>({
|
const [activeFilters, setActiveFilters] = useState<FilterState>({
|
||||||
rarity: [],
|
rarity: [],
|
||||||
category: [],
|
category: [],
|
||||||
priceRange: { min: 0, max: 100000 },
|
priceRange: { min: 0, max: 100000 },
|
||||||
|
vocation: null,
|
||||||
});
|
});
|
||||||
const [sortBy, setSortBy] = useState("name");
|
const [sortBy, setSortBy] = useState("name");
|
||||||
|
|
||||||
@@ -60,7 +57,7 @@ export function MarketplaceFilters({
|
|||||||
{ value: "legendary", label: "Lendário", color: "bg-orange-500" },
|
{ value: "legendary", label: "Lendário", color: "bg-orange-500" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const categoryOptions = [
|
const categoryOptions = categoryOptionsOverride ?? [
|
||||||
{ value: "weapon", label: "Arma" },
|
{ value: "weapon", label: "Arma" },
|
||||||
{ value: "armor", label: "Armadura" },
|
{ value: "armor", label: "Armadura" },
|
||||||
{ value: "amulet", label: "Amuleto" },
|
{ value: "amulet", label: "Amuleto" },
|
||||||
@@ -70,13 +67,22 @@ export function MarketplaceFilters({
|
|||||||
{ value: "other", label: "Outros" },
|
{ value: "other", label: "Outros" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const vocationOptions = [
|
||||||
|
{ value: "Knight", label: "Knight" },
|
||||||
|
{ value: "Paladin", label: "Paladin" },
|
||||||
|
{ value: "Sorcerer", label: "Sorcerer" },
|
||||||
|
{ value: "Druid", label: "Druid" },
|
||||||
|
{ value: "Sorcerers and Druids", label: "Sorcerers and Druids" },
|
||||||
|
{ value: "Todas", label: "Todas" },
|
||||||
|
];
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: "name", label: "Nome", icon: SortAsc },
|
{ value: "name", label: "Nome (A→Z)", icon: SortAsc },
|
||||||
{ value: "price-asc", label: "Preço (Menor)", icon: SortAsc },
|
{ value: "level-asc", label: "Nível (Menor)", icon: SortAsc },
|
||||||
{ value: "price-desc", label: "Preço (Maior)", icon: SortDesc },
|
{ value: "level-desc", label: "Nível (Maior)", icon: SortDesc },
|
||||||
{ value: "rarity", label: "Raridade", icon: Star },
|
{ value: "weight-asc", label: "Peso (Leves)", icon: SortAsc },
|
||||||
{ value: "trending", label: "Tendência", icon: TrendingUp },
|
{ value: "weight-desc", label: "Peso (Pesados)", icon: SortDesc },
|
||||||
{ value: "recent", label: "Recente", icon: Clock },
|
{ value: "category", label: "Categoria (A→Z)", icon: SortAsc },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSearch = (query: string) => {
|
const handleSearch = (query: string) => {
|
||||||
@@ -114,18 +120,22 @@ export function MarketplaceFilters({
|
|||||||
rarity: [],
|
rarity: [],
|
||||||
category: [],
|
category: [],
|
||||||
priceRange: { min: 0, max: 100000 },
|
priceRange: { min: 0, max: 100000 },
|
||||||
|
vocation: null,
|
||||||
});
|
});
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
onFilter({
|
onFilter({
|
||||||
rarity: [],
|
rarity: [],
|
||||||
category: [],
|
category: [],
|
||||||
priceRange: { min: 0, max: 100000 },
|
priceRange: { min: 0, max: 100000 },
|
||||||
|
vocation: null,
|
||||||
});
|
});
|
||||||
onSearch("");
|
onSearch("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeFiltersCount =
|
const activeFiltersCount =
|
||||||
activeFilters.rarity.length + activeFilters.category.length;
|
activeFilters.rarity.length +
|
||||||
|
activeFilters.category.length +
|
||||||
|
(activeFilters.vocation ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -142,37 +152,39 @@ export function MarketplaceFilters({
|
|||||||
|
|
||||||
{/* Filtros e ordenação */}
|
{/* Filtros e ordenação */}
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
{/* Filtro por raridade */}
|
{/* Filtro por raridade (opcional) */}
|
||||||
<DropdownMenu>
|
{showRarity && (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="outline" className="gap-2">
|
<DropdownMenuTrigger asChild>
|
||||||
<Filter className="h-4 w-4" />
|
<Button variant="outline" className="gap-2">
|
||||||
Raridade
|
<Filter className="h-4 w-4" />
|
||||||
{activeFilters.rarity.length > 0 && (
|
Raridade
|
||||||
<Badge variant="secondary" className="ml-1">
|
{activeFilters.rarity.length > 0 && (
|
||||||
{activeFilters.rarity.length}
|
<Badge variant="secondary" className="ml-1">
|
||||||
</Badge>
|
{activeFilters.rarity.length}
|
||||||
)}
|
</Badge>
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-48">
|
|
||||||
<DropdownMenuLabel>Filtrar por Raridade</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{rarityOptions.map((option) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => toggleRarityFilter(option.value)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div className={`w-3 h-3 rounded-full ${option.color}`} />
|
|
||||||
<span>{option.label}</span>
|
|
||||||
{activeFilters.rarity.includes(option.value) && (
|
|
||||||
<X className="h-3 w-3 ml-auto" />
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
))}
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuContent>
|
<DropdownMenuContent className="w-48">
|
||||||
</DropdownMenu>
|
<DropdownMenuLabel>Filtrar por Raridade</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{rarityOptions.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => toggleRarityFilter(option.value)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className={`w-3 h-3 rounded-full ${option.color}`} />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{activeFilters.rarity.includes(option.value) && (
|
||||||
|
<X className="h-3 w-3 ml-auto" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filtro por categoria */}
|
{/* Filtro por categoria */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -205,6 +217,47 @@ export function MarketplaceFilters({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Filtro por Vocação */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Vocação
|
||||||
|
{activeFilters.vocation && (
|
||||||
|
<Badge variant="secondary" className="ml-1">
|
||||||
|
1
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-48">
|
||||||
|
<DropdownMenuLabel>Filtrar por Vocação</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{vocationOptions.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
const newFilters = {
|
||||||
|
...activeFilters,
|
||||||
|
vocation:
|
||||||
|
activeFilters.vocation === option.value
|
||||||
|
? null
|
||||||
|
: option.value,
|
||||||
|
};
|
||||||
|
setActiveFilters(newFilters);
|
||||||
|
onFilter(newFilters);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{activeFilters.vocation === option.value && (
|
||||||
|
<X className="h-3 w-3 ml-auto" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Ordenação */}
|
{/* Ordenação */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -264,7 +317,7 @@ export function MarketplaceFilters({
|
|||||||
})}
|
})}
|
||||||
{activeFilters.category.map((category) => {
|
{activeFilters.category.map((category) => {
|
||||||
const option = categoryOptions.find(
|
const option = categoryOptions.find(
|
||||||
(opt) => opt.value === category
|
(opt) => opt.value === category,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -278,6 +331,21 @@ export function MarketplaceFilters({
|
|||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{activeFilters.vocation && (
|
||||||
|
<Badge
|
||||||
|
key={activeFilters.vocation}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
const newFilters = { ...activeFilters, vocation: null };
|
||||||
|
setActiveFilters(newFilters);
|
||||||
|
onFilter(newFilters);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeFilters.vocation}
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -289,8 +357,3 @@ export function MarketplaceFilters({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,275 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
Heart,
|
||||||
|
MessageSquare,
|
||||||
|
MousePointerClick,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useSession } from "@/lib/hooks/use-session";
|
||||||
|
|
||||||
|
interface AdAnalytics {
|
||||||
|
ad_id: number;
|
||||||
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
ctr: number;
|
||||||
|
avg_view_duration: number;
|
||||||
|
bounce_rate: number;
|
||||||
|
inquiries: number;
|
||||||
|
favorites: number;
|
||||||
|
conversion_rate: number;
|
||||||
|
time_to_sell: number | null;
|
||||||
|
last_updated: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Listing {
|
||||||
|
listing_id: number;
|
||||||
|
item_name: string;
|
||||||
|
listing_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsDashboard() {
|
||||||
|
const { user } = useSession();
|
||||||
|
const [listings, setListings] = useState<Listing[]>([]);
|
||||||
|
const [selectedAd, setSelectedAd] = useState<number | null>(null);
|
||||||
|
const [analytics, setAnalytics] = useState<AdAnalytics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadUserListings = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/listings/my");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setListings(data.listings || []);
|
||||||
|
if (data.listings && data.listings.length > 0) {
|
||||||
|
setSelectedAd(data.listings[0].listing_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar anúncios:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAnalytics = async (adId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/telemetry/analytics?adId=${adId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAnalytics(data.analytics);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar analytics:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.userId) {
|
||||||
|
loadUserListings();
|
||||||
|
}
|
||||||
|
}, [user?.userId, loadUserListings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAd) {
|
||||||
|
loadAnalytics(selectedAd);
|
||||||
|
}
|
||||||
|
}, [selectedAd, loadAnalytics]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Faça login para ver suas analytics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-32" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Você ainda não tem anúncios. Crie um anúncio para ver as analytics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Analytics dos Anúncios</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Acompanhe o desempenho dos seus anúncios
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seletor de anúncio */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Selecione um anúncio</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-2 border rounded-md"
|
||||||
|
value={selectedAd || ""}
|
||||||
|
onChange={(e) => setSelectedAd(parseInt(e.target.value, 10))}
|
||||||
|
>
|
||||||
|
{listings.map((listing) => (
|
||||||
|
<option key={listing.listing_id} value={listing.listing_id}>
|
||||||
|
{listing.item_name} ({listing.listing_status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Métricas */}
|
||||||
|
{analytics && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
Impressões
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">{analytics.impressions}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Visualizações do anúncio
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MousePointerClick className="h-5 w-5" />
|
||||||
|
Cliques
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">{analytics.clicks}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
CTR: {analytics.ctr.toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5" />
|
||||||
|
Duração Média
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">
|
||||||
|
{analytics.avg_view_duration.toFixed(1)}s
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Tempo médio de visualização
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
Bounce Rate
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">
|
||||||
|
{analytics.bounce_rate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Usuários que saíram em menos de 3s
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Inquiries
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">{analytics.inquiries}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Mensagens recebidas
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Heart className="h-5 w-5" />
|
||||||
|
Favoritos
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">{analytics.favorites}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Usuários que favoritaram
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{analytics.conversion_rate > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Taxa de Conversão</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">
|
||||||
|
{analytics.conversion_rate.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Taxa de vendas
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analytics.time_to_sell && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tempo até Venda</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">
|
||||||
|
{Math.round(analytics.time_to_sell)}h
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Tempo médio até ser vendido
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Clock } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface AuctionCountdownProps {
|
||||||
|
endsAt: Date | string | null;
|
||||||
|
className?: string;
|
||||||
|
showIcon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuctionCountdown({
|
||||||
|
endsAt,
|
||||||
|
className,
|
||||||
|
showIcon = true,
|
||||||
|
}: AuctionCountdownProps) {
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState<{
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!endsAt) {
|
||||||
|
setTimeRemaining(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTimeRemaining = () => {
|
||||||
|
const endTime = new Date(endsAt).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const total = endTime - now;
|
||||||
|
|
||||||
|
if (total <= 0) {
|
||||||
|
setTimeRemaining({ hours: 0, minutes: 0, seconds: 0, total: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(total / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((total % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((total % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
setTimeRemaining({ hours, minutes, seconds, total });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calcular imediatamente
|
||||||
|
calculateTimeRemaining();
|
||||||
|
|
||||||
|
// Atualizar a cada segundo
|
||||||
|
const interval = setInterval(calculateTimeRemaining, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [endsAt]);
|
||||||
|
|
||||||
|
if (!endsAt || !timeRemaining) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar cor baseada no tempo restante
|
||||||
|
const getColorClass = () => {
|
||||||
|
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
|
||||||
|
|
||||||
|
if (timeRemaining.total <= 0) {
|
||||||
|
return "text-red-600 dark:text-red-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalMinutes < 5) {
|
||||||
|
return "text-red-500 dark:text-red-400 font-semibold animate-pulse";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalMinutes < 60) {
|
||||||
|
return "text-yellow-600 dark:text-yellow-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "text-green-600 dark:text-green-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formatar texto
|
||||||
|
const getTimeText = () => {
|
||||||
|
if (timeRemaining.total <= 0) {
|
||||||
|
return "Leilão encerrado";
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
|
||||||
|
|
||||||
|
if (totalMinutes < 5) {
|
||||||
|
return "Termina em breve";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeRemaining.hours > 0) {
|
||||||
|
return `${timeRemaining.hours}h ${timeRemaining.minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeRemaining.minutes > 0) {
|
||||||
|
return `${timeRemaining.minutes}m ${timeRemaining.seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${timeRemaining.seconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-1.5", className)}>
|
||||||
|
{showIcon && <Clock className={cn("h-3.5 w-3.5", getColorClass())} />}
|
||||||
|
<span className={cn("text-xs font-medium", getColorClass())}>
|
||||||
|
{getTimeText()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, TrendingUp } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useSession } from "@/lib/hooks/use-session";
|
||||||
|
|
||||||
|
interface Listing {
|
||||||
|
listing_id: number;
|
||||||
|
item_name: string;
|
||||||
|
quantity: number;
|
||||||
|
current_bid_amount: number | null;
|
||||||
|
min_bid_increment: number | null;
|
||||||
|
initial_price_pix: number | null;
|
||||||
|
initial_price_tibia_coins: number | null;
|
||||||
|
initial_price_gold_coins: number | null;
|
||||||
|
price_pix: number | null;
|
||||||
|
price_tibia_coins: number | null;
|
||||||
|
price_gold_coins: number | null;
|
||||||
|
auction_ends_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BidModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
listing: Listing | null;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BidModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
listing,
|
||||||
|
onSuccess,
|
||||||
|
}: BidModalProps) {
|
||||||
|
const { user } = useSession();
|
||||||
|
const [bidAmount, setBidAmount] = useState<string>("");
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [bidHistory, setBidHistory] = useState<
|
||||||
|
Array<{
|
||||||
|
bid_id: number;
|
||||||
|
bidder_id: number;
|
||||||
|
bidder_name: string;
|
||||||
|
bid_amount: number;
|
||||||
|
bid_type: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
|
||||||
|
if (!listing) return null;
|
||||||
|
|
||||||
|
// Calcular valores
|
||||||
|
const currentBid = listing.current_bid_amount || 0;
|
||||||
|
const minIncrement = listing.min_bid_increment || 10;
|
||||||
|
const minBid = currentBid + minIncrement;
|
||||||
|
const initialPrice =
|
||||||
|
listing.initial_price_pix ||
|
||||||
|
listing.initial_price_tibia_coins ||
|
||||||
|
listing.initial_price_gold_coins ||
|
||||||
|
0;
|
||||||
|
|
||||||
|
// Determinar métodos de pagamento disponíveis para lance
|
||||||
|
const availableMethods: string[] = [];
|
||||||
|
if (listing.price_pix || listing.initial_price_pix)
|
||||||
|
availableMethods.push("pix");
|
||||||
|
if (listing.price_tibia_coins || listing.initial_price_tibia_coins)
|
||||||
|
availableMethods.push("tc");
|
||||||
|
if (listing.price_gold_coins || listing.initial_price_gold_coins)
|
||||||
|
availableMethods.push("gp");
|
||||||
|
|
||||||
|
// Selecionar primeiro método disponível por padrão
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableMethods.length > 0 && !paymentMethod) {
|
||||||
|
setPaymentMethod(availableMethods[0]);
|
||||||
|
}
|
||||||
|
}, [paymentMethod]);
|
||||||
|
|
||||||
|
// Atualizar valor do lance quando mudar
|
||||||
|
useEffect(() => {
|
||||||
|
if (bidAmount === "" && minBid > 0) {
|
||||||
|
setBidAmount(minBid.toFixed(2));
|
||||||
|
}
|
||||||
|
}, [minBid, bidAmount]);
|
||||||
|
|
||||||
|
const fetchBidHistory = async () => {
|
||||||
|
if (!listing) return;
|
||||||
|
|
||||||
|
setLoadingHistory(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/listings/${listing.listing_id}/bids?limit=5`,
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setBidHistory(data.bids || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao buscar histórico de lances:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingHistory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Buscar histórico de lances quando abrir modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && listing) {
|
||||||
|
fetchBidHistory();
|
||||||
|
}
|
||||||
|
}, [open, listing?.listing_id, fetchBidHistory, listing]);
|
||||||
|
|
||||||
|
const handleBid = async () => {
|
||||||
|
const amount = parseFloat(bidAmount);
|
||||||
|
|
||||||
|
if (Number.isNaN(amount) || amount <= 0) {
|
||||||
|
setError("Valor do lance inválido");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount < minBid) {
|
||||||
|
setError(
|
||||||
|
`Lance mínimo é ${minBid.toFixed(2)} (lance atual: ${currentBid.toFixed(2)} + incremento: ${minIncrement.toFixed(2)})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paymentMethod) {
|
||||||
|
setError("Selecione um método de pagamento");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/listings/${listing.listing_id}/bid`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount,
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.error || "Erro ao processar lance");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sucesso - atualizar histórico
|
||||||
|
await fetchBidHistory();
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
setBidAmount("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erro ao processar lance");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price: number | null) => {
|
||||||
|
if (!price) return "N/A";
|
||||||
|
return `R$ ${price.toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Dar Lance</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Informe o valor do seu lance para este leilão
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Resumo do item */}
|
||||||
|
<div className="bg-muted p-4 rounded-lg">
|
||||||
|
<h3 className="font-semibold mb-2">{listing.item_name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Quantidade: {listing.quantity}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Informações do leilão */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Preço Inicial
|
||||||
|
</Label>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{formatPrice(initialPrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Lance Atual
|
||||||
|
</Label>
|
||||||
|
<p className="text-lg font-semibold text-primary">
|
||||||
|
{currentBid > 0 ? formatPrice(currentBid) : "Sem lances"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Incremento Mínimo
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatPrice(minIncrement)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Lance Mínimo Necessário
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm font-semibold text-green-600">
|
||||||
|
{formatPrice(minBid)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listing.auction_ends_at && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Término do Leilão
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm">
|
||||||
|
{new Date(listing.auction_ends_at).toLocaleString("pt-BR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Histórico de lances */}
|
||||||
|
{bidHistory.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
Histórico de Lances (Últimos 5)
|
||||||
|
</Label>
|
||||||
|
<div className="bg-muted rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{bidHistory.map((bid, index) => {
|
||||||
|
const isCurrentUser = user?.userId === bid.bidder_id;
|
||||||
|
const isHighest = index === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={bid.bid_id}
|
||||||
|
className={`p-2 rounded ${
|
||||||
|
isCurrentUser
|
||||||
|
? "bg-primary/10 border border-primary/20"
|
||||||
|
: ""
|
||||||
|
} ${isHighest ? "border-l-2 border-l-primary" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{isCurrentUser ? "Você" : bid.bidder_name}
|
||||||
|
</span>
|
||||||
|
{isHighest && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Líder
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isCurrentUser && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Seu lance
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(bid.created_at).toLocaleString("pt-BR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
R$ {bid.bid_amount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingHistory && (
|
||||||
|
<div className="text-center text-sm text-muted-foreground py-4">
|
||||||
|
Carregando histórico...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Input do lance */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bid-amount">Valor do Lance (R$)</Label>
|
||||||
|
<Input
|
||||||
|
id="bid-amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={minBid}
|
||||||
|
value={bidAmount}
|
||||||
|
onChange={(e) => setBidAmount(e.target.value)}
|
||||||
|
placeholder={minBid.toFixed(2)}
|
||||||
|
className="text-lg"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Valor mínimo: {formatPrice(minBid)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seleção de método de pagamento */}
|
||||||
|
{availableMethods.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="payment-method">Método de Pagamento</Label>
|
||||||
|
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||||
|
<SelectTrigger id="payment-method">
|
||||||
|
<SelectValue placeholder="Selecione o método" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableMethods.map((method) => (
|
||||||
|
<SelectItem key={method} value={method}>
|
||||||
|
{method.toUpperCase()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Botões */}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleBid}
|
||||||
|
disabled={
|
||||||
|
loading ||
|
||||||
|
!bidAmount ||
|
||||||
|
parseFloat(bidAmount) < minBid ||
|
||||||
|
!paymentMethod
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Processando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TrendingUp className="h-4 w-4 mr-2" />
|
||||||
|
Confirmar Lance
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Package } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useSession } from "@/lib/hooks/use-session";
|
||||||
|
import { ItemSearch } from "./ItemSearch";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category?: string;
|
||||||
|
image?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateAdModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
serverId: number;
|
||||||
|
serverName: string;
|
||||||
|
serverType: string;
|
||||||
|
location: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateAdModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: CreateAdModalProps) {
|
||||||
|
const { user } = useSession();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
|
const [loadingServers, setLoadingServers] = useState(false);
|
||||||
|
|
||||||
|
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
|
||||||
|
const [tradeItem, setTradeItem] = useState<Item | null>(null);
|
||||||
|
const [acceptTrade, setAcceptTrade] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
quantity: "1",
|
||||||
|
server_name: "",
|
||||||
|
price_pix: "",
|
||||||
|
price_tibia_coins: "",
|
||||||
|
price_gold_coins: "",
|
||||||
|
trade_item_quantity: "1",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleItemSelect = (item: Item | null) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTradeItemSelect = (item: Item | null) => {
|
||||||
|
setTradeItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validar item selecionado
|
||||||
|
if (!selectedItem) {
|
||||||
|
setError("Selecione um item para vender");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar pelo menos um método de pagamento (preço ou troca)
|
||||||
|
const hasPrice =
|
||||||
|
formData.price_pix ||
|
||||||
|
formData.price_tibia_coins ||
|
||||||
|
formData.price_gold_coins;
|
||||||
|
|
||||||
|
if (!hasPrice && (!acceptTrade || !tradeItem)) {
|
||||||
|
setError(
|
||||||
|
"Informe pelo menos um preço (PIX, TC ou GP) ou configure uma troca por item",
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar troca se aceita troca
|
||||||
|
if (acceptTrade && !tradeItem) {
|
||||||
|
setError("Selecione o item que deseja receber em troca");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/listings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
item_id: selectedItem.id,
|
||||||
|
item_name: selectedItem.name,
|
||||||
|
quantity: parseInt(formData.quantity, 10),
|
||||||
|
server_name: formData.server_name,
|
||||||
|
price_pix: formData.price_pix ? parseFloat(formData.price_pix) : null,
|
||||||
|
price_tibia_coins: formData.price_tibia_coins
|
||||||
|
? parseInt(formData.price_tibia_coins, 10)
|
||||||
|
: null,
|
||||||
|
price_gold_coins: formData.price_gold_coins
|
||||||
|
? parseInt(formData.price_gold_coins, 10)
|
||||||
|
: null,
|
||||||
|
description: formData.description || null,
|
||||||
|
trade_item_id: acceptTrade && tradeItem ? tradeItem.id : null,
|
||||||
|
trade_item_name: acceptTrade && tradeItem ? tradeItem.name : null,
|
||||||
|
trade_item_quantity:
|
||||||
|
acceptTrade && tradeItem
|
||||||
|
? parseInt(formData.trade_item_quantity, 10)
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (data?.details?.length) {
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
item_id: "Item",
|
||||||
|
item_id_unified: "Item",
|
||||||
|
item_name: "Nome do item",
|
||||||
|
quantity: "Quantidade",
|
||||||
|
server_name: "Servidor",
|
||||||
|
price_pix: "Preço PIX",
|
||||||
|
price_tibia_coins: "Preço Tibia Coins",
|
||||||
|
price_gold_coins: "Preço Gold Coins",
|
||||||
|
trade_item_id: "Item de troca",
|
||||||
|
trade_item_name: "Nome do item de troca",
|
||||||
|
trade_item_quantity: "Quantidade do item de troca",
|
||||||
|
auction_ends_at: "Término do leilão",
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationMessages = data.details.map(
|
||||||
|
(detail: { field?: string; message?: string }) => {
|
||||||
|
const fieldLabel = detail.field
|
||||||
|
? fieldLabels[detail.field] || detail.field
|
||||||
|
: "Campo";
|
||||||
|
return `${fieldLabel}: ${detail.message ?? "preenchimento obrigatório"}`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setError(validationMessages.join(" "));
|
||||||
|
} else {
|
||||||
|
setError(data?.error || "Erro ao criar anúncio");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpar formulário
|
||||||
|
setSelectedItem(null);
|
||||||
|
setTradeItem(null);
|
||||||
|
setAcceptTrade(false);
|
||||||
|
setFormData({
|
||||||
|
quantity: "1",
|
||||||
|
server_name: "",
|
||||||
|
price_pix: "",
|
||||||
|
price_tibia_coins: "",
|
||||||
|
price_gold_coins: "",
|
||||||
|
trade_item_quantity: "1",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("Erro ao criar anúncio. Tente novamente.");
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Carregar servidores ao abrir o modal
|
||||||
|
const loadServers = async () => {
|
||||||
|
setLoadingServers(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/servers");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setServers(data.servers || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar servidores:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingServers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadServers();
|
||||||
|
}
|
||||||
|
}, [open, loadServers]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!loading) {
|
||||||
|
setSelectedItem(null);
|
||||||
|
setTradeItem(null);
|
||||||
|
setAcceptTrade(false);
|
||||||
|
setFormData({
|
||||||
|
quantity: "1",
|
||||||
|
server_name: "",
|
||||||
|
price_pix: "",
|
||||||
|
price_tibia_coins: "",
|
||||||
|
price_gold_coins: "",
|
||||||
|
trade_item_quantity: "1",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Criar Novo Anúncio</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Preencha os dados do item que deseja vender
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Seleção do item a vender */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="item_search">Item a Vender *</Label>
|
||||||
|
<ItemSearch
|
||||||
|
onItemSelect={handleItemSelect}
|
||||||
|
selectedItem={selectedItem}
|
||||||
|
placeholder="Pesquise o nome do item..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="quantity">Quantidade *</Label>
|
||||||
|
<Input
|
||||||
|
id="quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
value={formData.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, quantity: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server_name">Servidor *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.server_name}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({ ...formData, server_name: value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
disabled={loadingServers}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="server_name">
|
||||||
|
<SelectValue placeholder="Selecione um servidor" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{servers.length === 0 && !loadingServers ? (
|
||||||
|
<SelectItem value="" disabled>
|
||||||
|
Nenhum servidor disponível
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
servers.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverName}
|
||||||
|
>
|
||||||
|
{server.serverName}
|
||||||
|
{server.location && ` (${server.location})`}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Preços */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Preços (opcional se aceitar troca)</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="price_pix" className="text-sm">
|
||||||
|
PIX (R$)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="price_pix"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={formData.price_pix}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, price_pix: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="price_tibia_coins" className="text-sm">
|
||||||
|
Tibia Coins
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="price_tibia_coins"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.price_tibia_coins}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
price_tibia_coins: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="price_gold_coins" className="text-sm">
|
||||||
|
Gold Coins
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="price_gold_coins"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.price_gold_coins}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
price_gold_coins: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Troca por item */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="accept_trade"
|
||||||
|
checked={acceptTrade}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setAcceptTrade(checked as boolean);
|
||||||
|
if (!checked) {
|
||||||
|
setTradeItem(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="accept_trade"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Package className="h-4 w-4" />
|
||||||
|
Aceitar troca por item (além dos valores monetários)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{acceptTrade && (
|
||||||
|
<div className="space-y-4 pl-6 border-l-2 border-primary/20">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Item Desejado em Troca *</Label>
|
||||||
|
<ItemSearch
|
||||||
|
onItemSelect={handleTradeItemSelect}
|
||||||
|
selectedItem={tradeItem}
|
||||||
|
placeholder="Pesquise o item que deseja receber..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tradeItem && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="trade_item_quantity">
|
||||||
|
Quantidade do Item de Troca *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="trade_item_quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
value={formData.trade_item_quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
trade_item_quantity: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Descrição */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Descrição (opcional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
maxLength={5000}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Descreva o item, condições, etc..."
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formData.description.length}/5000 caracteres
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-destructive/10 text-destructive rounded-md text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Criando..." : "Criar Anúncio"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 100 of 2867 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user