Author SHA1 Message Date
evandro b95911f5a6 docs(readme): redesenha o README com hero em SVG [skip ci] 2026-10-24 04:58:07 -03:00
Evandro Fonseca Junior 41e82db2f6 ultima antes do format 2025-11-22 01:59:01 -03:00
Evandro Fonseca Junior b8a8d3cc17 fix(items): normalize item fields and improve data integrity checks
Ensure consistent field names and types between API and client components. Add fallback values for missing fields and implement better error logging for invalid items. Improve key generation for item rendering to prevent duplicate keys.
2025-11-11 03:29:10 -03:00
Evandro Fonseca Junior 6adc8d96e8 refactor: reorganize hooks and add safety checks
fix(api): add validation for listing and seller in bid route
feat(telemetry): add userId to MarketTelemetry
style(globe): add null check for canvas element
chore: add lint:fix script to package.json
2025-11-11 03:20:01 -03:00
Evandro Fonseca Junior 47c04de6fe style: clean up code formatting and remove trailing whitespace
refactor: reorganize imports and fix type imports
2025-11-11 03:08:36 -03:00
Evandro Fonseca Junior d3704602fd feat(items): add server field and improve item management
refactor(ai): enhance item structure generation with schema validation
feat(ai): add token usage tracking and cost calculation
fix(ui): improve item card number formatting with locale support
docs(items): add documentation for item value separation
test(items): add tests for value field validation
2025-11-11 03:04:49 -03:00
Evandro Fonseca Junior dc169f9eba style: remove trailing whitespace in admin categories fields route 2025-11-10 22:48:49 -03:00
Evandro Fonseca Junior f4bc93bc8a changes shopping 2025-11-09 15:37:53 -03:00
Evandro Fonseca Junior 62ab5b2608 feat(admin): centralize subscriptions under /admin?tab=subscriptions
Replaced the dedicated /admin/subscriptions page with a redirect to the central admin panel’s subscriptions tab. Removed session checks, imports, and the AdminSubscriptionDashboard since routing and access are now handled centrally. This streamlines admin UX and keeps access control unified.
2025-11-09 01:50:50 -03:00
Evandro Fonseca Junior d8e7d5d7e6 feat: enhance purchase intentions with chat integration and offer tracking
- Added MongoDB chat/message integration for seller responses
- Improved seller response validation and default messaging
- Integrated automatic chat creation/update when responding to intentions
- Added highest custom offer fields to listing queries and models
- Enhanced listing data with top offer information for buyers
2025-11-08 11:51:21 -03:00
Evandro Fonseca Junior d27526e026 feat(auth): include subscription tier in token and session payload
- Adds subscription tier info to access/refresh tokens.
- Stores tier details in session Redis record.
- Returns tier data in login/register response.
- Uses default tier when user has none.
2025-11-07 21:03:19 -03:00
Evandro Fonseca Junior 7f42def478 feat(market): add Minha Intenções tab with panel, hook, and badge
- Introduced "Minhas Intenções" tab visible only to logged-in users.
- Integrated MyPurchaseIntentionsPanel and useMyPurchaseIntentions hook.
- Added tab badge showing count of pending intentions.
- Extended session API to include user’s main character for downstream features.
2025-11-07 17:25:51 -03:00
Evandro Fonseca Junior f35fee944d refactor(register, listings, items): simplify transaction, remove unused variable, add explicit types
- Remove newUser variable and directly return created user from Prisma transaction in register route
- Add explicit casts for Prisma QueryMode to avoid TypeScript widening in listings search
- Fix items search and getItemBySource with any cast and ts-expect-error to satisfy type checks

Improves readability and type safety without changing behavior.
2025-11-05 04:38:07 -03:00
Evandro Fonseca Junior ab37b477df feat: implement market page with QueryProvider and dynamic listings
- wrap root layout with QueryProvider for React Query
- replace market UnderConstruction placeholder with MarketClient and real data fetching
- implement search, pagination, and sort via search params using getActiveListings
- add necessary client components and queries
- update dependencies (e.g., prisma, pkg-types)
2025-11-05 01:58:03 -03:00
Evandro Fonseca Junior 55eebde0ba feat: add user authentication UI to header component
Add login and register dialogs, user dropdown menu with profile link and logout option, and integrate with session management hook. Prevent hydration mismatches by conditionally rendering auth elements after mount. Update package-lock.json with new @esbuild dependencies for build tooling.
2025-11-04 02:33:29 -03:00
Evandro Fonseca Junior e641138887 refactor: replace MarketOffers component with MarketExample in home page
Renamed imported component from MarketOffers to MarketExample and updated its usage in the home page layout. Also removed rarity badge display from ItemCard to align with simplified design.
Related to component refactoring for consistency.
2025-10-30 19:17:46 -03:00
Evandro Fonseca Junior b8426efbf0 feat(nixpacks): add Node.js 18 and pnpm to nixPkgs list
Enhanced the nixpacks configuration by including Node.js 18 and pnpm in the nixPkgs array, expanding the development environment capabilities.
2025-10-30 19:01:54 -03:00
Evandro Fonseca Junior 27b3c579a8 fix: update .gitignore to exclude unnecessary directories
Modified .gitignore to ensure that specific directories are excluded from version control, preventing unnecessary tracking of generated data and maintaining a cleaner repository.
2025-10-30 18:59:09 -03:00
Evandro Fonseca Junior 9644e5c19f commit 2025-10-30 18:55:10 -03:00
Evandro Fonseca Junior 1671e4df58 Merge pull request #4 from evandrodevbr:main
Main
2025-10-30 18:50:25 -03:00
Evandro Fonseca Junior caac1827ae chore(.gitignore): uncomment /data/images to exclude from git
Uncommented the /data/images path in .gitignore to ensure the directory is excluded from version control. This prevents unnecessary tracking of image assets while maintaining proper version control of the codebase.
2025-10-30 18:48:29 -03:00
Evandro Fonseca Junior 0659f1fe99 chore(.gitignore): uncomment /data/images to enable directory tracking
This removes the ignore rule for `/data/images`, allowing git to track changes in the directory. The line was previously active in `.gitignore` but is now commented out to enable its contents to be versioned.
2025-10-30 18:47:30 -03:00
Evandro Fonseca Junior 2b790d8eb4 feat(library): add vocation filter, normalize categories, improve grid layout
Added vocation state and included it in query string builder, updated onFilter to accept a vocation parameter, improved grid layout with grid flow classes, normalized category names for consistent item icons, and refined stats mapping to handle type safety.
2025-10-30 18:44:52 -03:00
Evandro Fonseca Junior 44d7f8ce81 feat(item-card): add 'market'/'catalog' mode prop with defensive price checks
Introduces optional `mode` prop ("market"/"catalog") to conditionally render components like rarity badges. Adds defensive checks for missing price data and priceHistory properties to prevent runtime errors. New icons `Badge` and `Boxes` imported from lucide-react.
2025-10-30 14:10:46 -03:00
Evandro Fonseca Junior 29ba5aece0 feat: add TibiaWiki scraper project and update .gitignore
Integrates a Scrapy-based TibiaWiki scraper (spider, pipelines, SQLite/Images settings, README). Updates .gitignore to exclude generated data directories (/iconScript/data, /data/images) to keep the repository clean.
2025-10-30 00:55:25 -03:00
Evandro Fonseca Junior bf12f2c6f1 scrapping 2025-10-29 23:44:40 -03:00
Evandro Fonseca Junior 4ba3339df7 feat: customize README for Tibia Trade marketplace and enhance page accessibility
- Rewrote README.md to describe the Tibia Trade project, including features, setup instructions, technologies, and SEO keywords in Portuguese
- Removed "Pagodera" from thank you page text for rebranding
- Added role and aria-label to main element in home page for better accessibility
- Added metadata generation with localized titles and descriptions to prices page for improved SEO and user experience
2025-10-27 00:35:35 -03:00
Evandro Fonseca Junior 5e7f3a1354 Merge pull request #3 from evandrodevbr:main
Main
2025-10-26 23:31:51 -03:00
2867 changed files with 45162 additions and 1255 deletions

No files matched your search

+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/iconScript/data
/data/images
+91 -22
View File
@@ -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:
![Next.js 15.5.0](https://img.shields.io/badge/Next.js-15.5.0-ffd700?style=flat-square&labelColor=0a0a0a)
![React 19.1.0](https://img.shields.io/badge/React-19.1.0-ffd700?style=flat-square&labelColor=0a0a0a)
![PostgreSQL + Redis + MongoDB](https://img.shields.io/badge/dados-PostgreSQL_%C2%B7_Redis_%C2%B7_MongoDB-ffd700?style=flat-square&labelColor=0a0a0a)
![Status: em desenvolvimento](https://img.shields.io/badge/status-em_desenvolvimento-ffd700?style=flat-square&labelColor=0a0a0a)
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
[Plano de melhorias](docs/PLANO_MELHORIAS_MARKETPLACE.md) · [Configurar o AbacatePay](ABACATEPAY_SETUP.md) · [Issues](https://git.evandro.dev.br/evandro/pagodera/issues)
</div>
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:
```console
$ 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.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
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.
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>
+287
View File
@@ -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
+59
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+18
View File
@@ -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`);
}
+12 -12
View File
@@ -1,13 +1,13 @@
"use client";
import { useEffect } from "react";
import { useParams } from "next/navigation";
import confetti from "canvas-confetti";
import Header from "@/components/Header";
import { Button } from "@/components/ui/button";
import { BlurFade } from "@/components/ui/blur-fade";
import { Heart, Share2, Home } from "lucide-react";
import { Heart, Home, Share2 } from "lucide-react";
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() {
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">
<p className="text-lg leading-relaxed text-muted-foreground">
Sua generosidade nos ajuda a continuar desenvolvendo e mantendo
o Tibia Trade Pagodera. Cada contribuição faz toda a diferença
para nossa comunidade!
o Tibia Trade. Cada contribuição faz toda a diferença para nossa
comunidade!
</p>
</div>
@@ -86,8 +86,8 @@ export default function ThankYouPage() {
onClick={() => {
if (typeof navigator !== "undefined" && navigator.share) {
navigator.share({
title: "Tibia Trade Pagodera",
text: "Acabei de fazer uma doação para o Tibia Trade Pagodera! Ajude também!",
title: "Tibia Trade",
text: "Acabei de fazer uma doação para o Tibia Trade! Ajude também!",
url: window.location.origin,
});
} 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">
<p className="text-sm text-muted-foreground">
💡 <strong>Dica:</strong> Compartilhe nossa plataforma com seus
amigos! Quanto mais pessoas usarem o Tibia Trade Pagodera, mais
forte nossa comunidade fica.
amigos! Quanto mais pessoas usarem o Tibia Trade, mais forte
nossa comunidade fica.
</p>
</div>
</div>
+30 -14
View File
@@ -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 { BlurFade } from "@/components/ui/blur-fade";
import { NumberTicker } from "@/components/ui/number-ticker";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import Header from "@/components/Header";
import { ShareButton } from "@/components/share-button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Heart } from "lucide-react";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { ShareButton } from "@/components/share-button";
import { Badge } from "@/components/ui/badge";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
}): Promise<Metadata> {
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 {
title: t("title"),
description: t("subtitle"),
title: titles[locale as keyof typeof titles],
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
const monthlyCosts = {
const _monthlyCosts = {
vps: 69, // R$ 69/mês
devHours: 40, // média horas/mês
devRate: 50, // R$ 50/hora
+142 -32
View File
@@ -1,25 +1,100 @@
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 { getMessages } from "next-intl/server";
import { getMessages, setRequestLocale } from "next-intl/server";
import { ThemeProvider } from "@/components/theme-provider";
import "../globals.css";
import { QueryProvider } from "@/lib/providers/query-provider";
const nunitoSans = Nunito_Sans({
variable: "--font-nunito-sans",
subsets: ["latin"],
weight: ["200", "300", "400", "500", "600", "700", "800", "900"],
});
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const titles = {
pt: "Tibia Trade - Marketplace Seguro para Compra e Venda de Itens",
en: "Tibia Trade - Secure Marketplace for Buying and Selling Items",
};
export const metadata: Metadata = {
title: "Pagodera Trade",
description: "Markeplace servidor pagodera",
};
const descriptions = {
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.",
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({
children,
@@ -29,24 +104,59 @@ export default async function LocaleLayout({
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const messages = await getMessages();
const baseUrl = "https://tibiatrade.evandro.dev.br";
return (
<html lang={locale} suppressHydrationWarning>
<body
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
>
<NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark">
{children}
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
<QueryProvider>
<NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark">
<Script
id="structured-data-organization"
type="application/ld+json"
strategy="afterInteractive"
>
{JSON.stringify({
"@context": "https://schema.org",
"@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>
);
}
+333
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+456
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+85 -14
View File
@@ -1,39 +1,110 @@
import type { Metadata } from "next";
import Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
import { getActiveListings } from "@/lib/prisma/queries/listings";
import MarketClient from "./MarketClient";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
}): Promise<Metadata> {
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 {
title: t("market.title"),
description: t("market.description"),
title: titles[locale as keyof typeof titles],
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({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const { locale } = await params;
const t = await getTranslations({
locale,
namespace: "underConstruction.market",
const sp = (await searchParams) ?? {};
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 (
<div className="min-h-screen">
<Header />
<UnderConstruction
title={t("title")}
description={t("description")}
locale={locale}
/>
<main className="container mx-auto px-4 py-8">
<MarketClient
initial={{
listings: serializedListings as any,
total: listingsData.total,
page,
pageSize,
totalPages: Math.ceil(listingsData.total / pageSize),
}}
/>
</main>
</div>
);
}
+5 -5
View File
@@ -1,23 +1,23 @@
import { DonationFooterCard } from "@/components/donation-footer-card";
import Header from "@/components/Header";
import { EscrowBanner } from "@/components/sections/escrow-banner";
import { EscrowExplanation } from "@/components/sections/escrow-explanation";
import { FeaturedItems } from "@/components/sections/featured-items";
import { HeroSection } from "@/components/sections/hero-section";
import { HowItWorks } from "@/components/sections/how-it-works";
import { FeaturedItems } from "@/components/sections/featured-items";
import { MarketOffers } from "@/components/sections/market-offers";
import { MarketExample } from "@/components/sections/market-example";
import { MarketStats } from "@/components/sections/market-stats";
import { DonationFooterCard } from "@/components/donation-footer-card";
export default function Home() {
return (
<div className="min-h-screen">
<Header />
<EscrowBanner />
<main>
<main aria-label="Página principal do Tibia Trade">
<HeroSection />
<HowItWorks />
<FeaturedItems />
<MarketOffers />
<MarketExample />
<MarketStats />
</main>
<EscrowExplanation />
+17 -5
View File
@@ -1,17 +1,29 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
}): Promise<Metadata> {
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 {
title: t("prices.title"),
description: t("prices.description"),
title: titles[locale as keyof typeof titles],
description: descriptions[locale as keyof typeof descriptions],
};
}
+22 -5
View File
@@ -1,17 +1,34 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
}): Promise<Metadata> {
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 {
title: t("about.title"),
description: t("about.description"),
title: titles[locale as keyof typeof titles],
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 },
);
}
}
+99
View File
@@ -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 },
);
}
}
+110
View File
@@ -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 },
);
}
+356
View File
@@ -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 },
);
}
}
+223
View File
@@ -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 },
);
}
}
+91
View File
@@ -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 },
);
}
}
+80
View File
@@ -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 },
);
}
}
+272
View File
@@ -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 },
);
}
}
+207
View File
@@ -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 },
);
}
}
+271
View File
@@ -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 },
);
}
}
+78
View File
@@ -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 },
);
}
}
+72
View File
@@ -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 },
);
}
}
+266
View File
@@ -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 },
);
}
}
+70
View File
@@ -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 },
);
}
}
+8 -8
View File
@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay";
import { DONATION_PRODUCTS, DonorData } from "@/types/donation";
import { createDonationSchema } from "@/lib/validation-schemas";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import { createDonationSchema } from "@/lib/validation-schemas";
import { DONATION_PRODUCTS } from "@/types/donation";
export async function POST(request: NextRequest) {
try {
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -72,8 +72,8 @@ export async function POST(request: NextRequest) {
if (validatedData.tier === "custom") {
// Converter para centavos
finalAmount = Math.round(validatedData.customAmount! * 100);
finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount!.toFixed(
2
finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount?.toFixed(
2,
)}`;
secureLogger.info("💰 Valor customizado calculado", {
@@ -161,7 +161,7 @@ export async function POST(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { getPaymentStatus } from "@/lib/payment-cache";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
export async function GET(request: NextRequest) {
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
if (!pixId) {
return NextResponse.json(
{ 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);
return NextResponse.json(
{ error: "Erro ao verificar status" },
{ status: 500 }
{ status: 500 },
);
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
export async function GET(request: NextRequest) {
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
if (!billingId) {
return NextResponse.json(
{ 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 instanceof Error ? error.message : "Erro ao verificar cobrança",
},
{ status: 500 }
{ status: 500 },
);
}
}
+136
View File
@@ -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 });
}
}
+20
View File
@@ -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 });
}
}
+104
View File
@@ -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 },
);
}
}
+111
View File
@@ -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 },
);
}
}
+150
View File
@@ -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 },
);
}
}
+108
View File
@@ -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 },
);
}
}
+139
View File
@@ -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,
});
}
}
+338
View File
@@ -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 },
);
}
}
+154
View File
@@ -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 },
);
}
}
+112
View File
@@ -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 },
);
}
}
+251
View File
@@ -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 },
);
}
}
+330
View File
@@ -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 },
);
}
}
+60
View File
@@ -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 },
);
}
}
+52
View File
@@ -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 },
);
}
}
+76
View File
@@ -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 },
);
}
}
+230
View File
@@ -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 },
);
}
}
+54
View File
@@ -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 },
);
}
}
+94
View File
@@ -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 },
);
}
}
+202
View File
@@ -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 },
);
}
}
+49
View File
@@ -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 },
);
}
}
+15
View File
@@ -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",
});
}
+210
View File
@@ -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 },
);
}
}
+136
View File
@@ -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 },
);
}
}
+101
View File
@@ -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 },
);
}
}
+243
View File
@@ -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 },
);
}
}
+66
View File
@@ -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 },
);
}
}
+193
View File
@@ -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 },
);
}
}
+152
View File
@@ -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 },
);
}
}
+178
View File
@@ -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 },
);
}
}
+6 -7
View File
@@ -1,10 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay";
import { type NextRequest, NextResponse } from "next/server";
import { markAsPaid } from "@/lib/payment-cache";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
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
if (timeDiff > 5 * 60 * 1000) {
secureLogger.warn("Webhook rejeitado: timestamp inválido", {
timeDiff: timeDiff / 1000 + "s",
timeDiff: `${timeDiff / 1000}s`,
});
return NextResponse.json(
{ error: "Timestamp inválido" },
{ status: 400 }
{ status: 400 },
);
}
}
@@ -119,7 +118,7 @@ export async function POST(request: NextRequest) {
secureLogger.info(
"Registro de pagamento criado",
secureLogger.mask(donationRecord)
secureLogger.mask(donationRecord),
);
// Salvar no cache para frontend detectar via polling
@@ -150,7 +149,7 @@ export async function POST(request: NextRequest) {
secureLogger.error("Webhook error", error);
return NextResponse.json(
{ error: "Erro ao processar webhook" },
{ status: 500 }
{ status: 500 },
);
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 96 KiB

+42
View File
@@ -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>
);
}
+28
View File
@@ -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;
}
+52
View File
@@ -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
View File
@@ -1,5 +1,20 @@
"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 {
NavigationMenu,
NavigationMenuItem,
@@ -8,24 +23,48 @@ import {
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { WordRotate } from "@/components/ui/word-rotate";
import { ThemeToggle } from "./theme-toggle";
import { LanguageSwitcher } from "./language-switcher";
import { MobileMenu } from "./mobile-menu";
import { useSession } from "@/lib/hooks/use-session";
import { cn } from "@/lib/utils";
import { useTranslations, useLocale } from "next-intl";
import Link from "next/link";
import { LoginDialog } from "./auth/login-dialog";
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() {
const t = useTranslations("header.nav");
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 = [
{ href: `/${locale}/library`, label: "Library" },
{ href: `/${locale}/market`, label: t("market") },
{ href: `/${locale}/sobre`, label: t("about") },
{ href: `/${locale}/precos`, label: t("prices") },
{ href: `/${locale}/apoiar`, label: t("support"), highlight: true },
];
const navLinks = [
...links,
...(isAuthenticated && user?.userId === 1
? [{ href: `/${locale}/admin`, label: "Admin" }]
: []),
];
const handleLogout = async () => {
await logout();
};
return (
<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">
@@ -33,7 +72,7 @@ export default function Header() {
<div className="flex items-center gap-2">
<Link href="/">
<WordRotate
words={["Pagodera", "Tibia Trade"]}
words={["Tibia Trade"]}
className="text-lg font-semibold tracking-tight hover:text-primary transition-colors"
/>
</Link>
@@ -42,7 +81,7 @@ export default function Header() {
{/* Desktop Navigation */}
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
{links.map(({ href, label, highlight }) => (
{navLinks.map(({ href, label, highlight }) => (
<NavigationMenuItem key={href}>
<NavigationMenuLink asChild>
<Link
@@ -51,7 +90,7 @@ export default function Header() {
navigationMenuTriggerStyle(),
"bg-transparent",
highlight &&
"bg-primary text-primary-foreground hover:bg-primary/90"
"bg-primary text-primary-foreground hover:bg-primary/90",
)}
>
{label}
@@ -66,13 +105,101 @@ export default function Header() {
<div className="hidden md:flex items-center gap-2">
<ThemeToggle />
<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>
{/* Mobile Menu */}
<div className="md:hidden">
<MobileMenu links={links} />
<MobileMenu links={navLinks} />
</div>
</div>
{/* Login Dialog */}
<LoginDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen} />
{/* Register Dialog */}
<RegisterDialog
open={registerDialogOpen}
onOpenChange={setRegisterDialogOpen}
/>
</header>
);
}
+86
View File
@@ -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>
);
}
+49
View File
@@ -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
+652
View File
@@ -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>
);
}
+381
View File
@@ -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";
+227
View File
@@ -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>
);
}
+772
View File
@@ -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>
);
}
+118
View File
@@ -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>
);
}
+699
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+117
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+26 -27
View File
@@ -1,32 +1,31 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import confetti from "canvas-confetti";
import {
Heart,
Coffee,
Pizza,
Loader2,
Copy,
Check,
Lock,
User,
CheckCircle,
CheckCircle2,
DollarSign,
Coffee,
Copy,
Heart,
Loader2,
Lock,
Pizza,
User,
} 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DonationTier, DonationResponse } from "@/types/donation";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { DonationResponse, DonationTier } from "@/types/donation";
interface DonationCardProps extends DonationTier {}
@@ -61,7 +60,7 @@ export function DonationCard({
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
const [paymentConfirmed, setPaymentConfirmed] = useState(false);
const [_paymentConfirmed, setPaymentConfirmed] = useState(false);
const [showThankYouModal, setShowThankYouModal] = useState(false);
const t = useTranslations("donation");
@@ -94,7 +93,7 @@ export function DonationCard({
const updateTimer = () => {
try {
const expires = new Date(pixData!.expiresAt!);
const expires = new Date(pixData?.expiresAt!);
const now = new Date();
const diff = expires.getTime() - now.getTime();
@@ -118,7 +117,7 @@ export function DonationCard({
try {
const response = await fetch(
`/api/donations/status?pixId=${pixData.pixId}`
`/api/donations/status?pixId=${pixData.pixId}`,
);
const data = await response.json();
@@ -346,7 +345,7 @@ export function DonationCard({
const isValidCustomAmount = () => {
const amount = parseFloat(customAmount);
return (
!isNaN(amount) &&
!Number.isNaN(amount) &&
amount >= customProduct.minAmount &&
amount <= customProduct.maxAmount
);
@@ -363,12 +362,12 @@ export function DonationCard({
// Garante apenas um separador decimal
const parts = normalized.split(".");
if (parts.length > 2) {
return parts[0] + "." + parts.slice(1).join("");
return `${parts[0]}.${parts.slice(1).join("")}`;
}
// Limita 2 casas decimais
if (parts[1]?.length > 2) {
return parts[0] + "." + parts[1].substring(0, 2);
return `${parts[0]}.${parts[1].substring(0, 2)}`;
}
return normalized;
@@ -396,12 +395,12 @@ export function DonationCard({
setError("Digite um valor");
return;
}
const amount = parseFloat(customAmount);
const _amount = parseFloat(customAmount);
if (!isValidCustomAmount()) {
setError(
`Valor deve estar entre R$ ${customProduct.minAmount.toFixed(
2
)} e R$ ${customProduct.maxAmount.toFixed(2)}`
2,
)} e R$ ${customProduct.maxAmount.toFixed(2)}`,
);
return;
}
@@ -463,10 +462,10 @@ export function DonationCard({
<p className="text-xs text-red-500">
{parseFloat(customAmount) < customProduct.minAmount
? `${t(
"form.minAmount"
"form.minAmount",
)} ${customProduct.minAmount.toFixed(2)}`
: `${t(
"form.maxAmount"
"form.maxAmount",
)} ${customProduct.maxAmount.toFixed(2)}`}
</p>
)}
@@ -485,7 +484,7 @@ export function DonationCard({
</>
) : customAmount ? (
`${t("form.donate")} ${parseFloat(
customAmount || "0"
customAmount || "0",
).toFixed(2)}`
) : (
t("form.chooseAmount")
+4 -4
View File
@@ -1,10 +1,10 @@
"use client";
import { Button } from "@/components/ui/button";
import { Heart, Coffee } from "lucide-react";
import { BlurFade } from "@/components/ui/blur-fade";
import { useTranslations } from "next-intl";
import { Coffee, Heart } from "lucide-react";
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() {
const t = useTranslations("donation");
+565 -191
View File
@@ -1,48 +1,226 @@
"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 {
TrendingUp,
TrendingDown,
Sword,
Shield,
Weight,
Heart,
Star,
ArrowDown,
ArrowUp,
Boxes,
Clock,
Coins,
ArrowUp,
ArrowDown,
Minus,
Zap,
Flame,
Snowflake,
Zap as Energy,
Skull,
Cross,
Zap as Energy,
Flame,
FlaskConical,
Gem,
Heart,
Leaf,
Minus,
ScrollText,
Shield,
Skull,
Snowflake,
Star,
Sword,
TrendingDown,
TrendingUp,
Weight,
Wrench,
Zap,
} from "lucide-react";
import { useState } from "react";
import {
getRarityColor,
getRarityBgColor,
getRarityGlow,
} from "@/lib/currency-utils";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MagicCard } from "@/components/ui/magic-card";
import { PriceDisplay } from "@/components/ui/price-display";
import { formatNumber } from "@/lib/utils/formatters";
import type { TibiaItem } from "@/types/item";
interface ItemCardProps {
item: TibiaItem;
mode?: "market" | "catalog";
}
export function ItemCard({ item }: ItemCardProps) {
export function ItemCard({ item, mode = "market" }: ItemCardProps) {
const t = useTranslations("home.item");
const locale = useLocale();
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",
uncommon: "bg-green-500",
rare: "bg-blue-500",
@@ -57,28 +235,44 @@ export function ItemCard({ item }: ItemCardProps) {
legs: Shield,
boots: Shield,
shield: Shield,
amulet: "💎",
ring: "💍",
potion: "🧪",
rune: "📜",
tool: "🔧",
ammunition: "🏹",
other: "📦",
};
amulet: Gem,
ring: Gem,
potion: FlaskConical,
rune: ScrollText,
tool: Wrench,
ammunition: Boxes,
other: Boxes,
} as const;
const CategoryIcon = categoryIcons[item.category];
// Calcular diferença percentual entre compra e venda
const priceDifference =
((item.price.buy - item.price.sell) / item.price.sell) * 100;
const isHotDeal = priceDifference > 20; // Hot deal se diferença > 20%
// Calcular diferença percentual entre compra e venda (somente quando houver preço)
const hasPrice =
item &&
(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
const getPriceTrend = () => {
if (item.priceHistory.highest > item.priceHistory.average * 1.1)
return "up";
if (item.priceHistory.lowest < item.priceHistory.average * 0.9)
return "down";
const ph = (item as any).priceHistory;
if (
!ph ||
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";
};
@@ -89,31 +283,18 @@ export function ItemCard({ item }: ItemCardProps) {
trend === "up"
? "text-green-500"
: trend === "down"
? "text-red-500"
: "text-gray-500";
? "text-red-500"
: "text-gray-500";
return (
<div
className="group relative h-full"
className="group relative"
onMouseEnter={() => setIsHovered(true)}
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 */}
<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 */}
<Button
variant="ghost"
@@ -130,22 +311,33 @@ export function ItemCard({ item }: ItemCardProps) {
/>
</Button>
{/* Hot Deal Badge */}
{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">
🔥 Hot Deal
{/* Hot Deal Badge (apenas em modo market) */}
{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 flex items-center gap-1">
<Flame className="h-3 w-3" />
Hot Deal
</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="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
{typeof CategoryIcon === "string" ? (
CategoryIcon
) : (
<CategoryIcon className="w-10 h-10" />
)}
</div>
{item.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.image}
alt={item.name}
className="w-20 h-20 object-contain drop-shadow-sm"
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>
@@ -162,6 +354,27 @@ export function ItemCard({ item }: ItemCardProps) {
<Clock className="h-3 w-3" />
<span>Atualizado há 5 min</span>
</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>
{/* Estatísticas agrupadas */}
@@ -171,24 +384,36 @@ export function ItemCard({ item }: ItemCardProps) {
{item.stats.attack && (
<div className="flex items-center gap-1.5 text-sm">
<Sword className="h-4 w-4 text-primary" />
<span className="font-medium">
⚔️ {item.stats.attack} ATK
</span>
<span className="font-medium">{item.stats.attack} ATK</span>
</div>
)}
{item.stats.defense && (
<div className="flex items-center gap-1.5 text-sm">
<Shield className="h-4 w-4 text-primary" />
<span className="font-medium">
🛡️ {item.stats.defense} DEF
{item.stats.defense} DEF
</span>
</div>
)}
{item.stats.armor && (
<div className="flex items-center gap-1.5 text-sm">
<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">
🛡️ {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>
</div>
)}
@@ -196,157 +421,306 @@ export function ItemCard({ item }: ItemCardProps) {
{/* Proteções elementais */}
{item.protections && (
<div className="flex flex-wrap gap-2">
<ScrollRow ariaLabel="protections">
{item.protections.fire && (
<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" />
<span>🔥 Fire +{item.protections.fire}%</span>
<span>Fire +{item.protections.fire}%</span>
</div>
)}
{item.protections.ice && (
<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" />
<span>❄️ Ice +{item.protections.ice}%</span>
<span>Ice +{item.protections.ice}%</span>
</div>
)}
{item.protections.energy && (
<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" />
<span>⚡ Energy +{item.protections.energy}%</span>
<span>Energy +{item.protections.energy}%</span>
</div>
)}
</div>
</ScrollRow>
)}
{/* Requisitos */}
{item.requirements && (
<div className="space-y-1">
{item.requirements.level && (
<div className="text-xs text-muted-foreground">
📊 Level {item.requirements.level}+
<div className="flex flex-wrap gap-2">
{typeof (item as any).requirements?.level === "number" && (
<div className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-1 rounded">
<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>
)}
{item.requirements.vocation && (
<div className="text-xs text-muted-foreground">
👤 Best for {item.requirements.vocation}
Best for {formatVocation(item.requirements.vocation)}
</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 && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<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>
)}
{/* Divisor visual */}
<div className="border-t border-border/50" />
{/* 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>
{(item as any).dropa_de &&
(() => {
const raw = String((item as any).dropa_de || "");
const normalized = raw.trim();
// Casos especiais de "nenhum/ninguém"
const none = /^(nenhum|nenhuma|ningu[eé]m)\.?$/i.test(normalized);
if (none) {
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<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>
<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"
}`}
{mode === "market" && item.price && (
<>
{/* Divisor visual */}
<div className="border-t border-border/50" />
{/* 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 ? "+" : ""}
{priceDifference.toFixed(1)}% diferença
</span>
{t("viewDetails")}
</Button>
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
size="sm"
>
{t("buyNow")}
</Button>
</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>
</MagicCard>
</div>
+19 -8
View File
@@ -1,8 +1,9 @@
"use client";
import { Globe } from "lucide-react";
import { useLocale } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -10,7 +11,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "next-intl";
const languages = [
{ code: "pt", name: "Português", flag: "🇧🇷" },
@@ -22,6 +22,12 @@ export function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("header");
const [mounted, setMounted] = useState(false);
// Evitar hydration mismatch com Radix UI
useEffect(() => {
setMounted(true);
}, []);
const handleLanguageChange = (newLocale: string) => {
// Remove the current locale from the pathname
@@ -30,7 +36,17 @@ export function LanguageSwitcher() {
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 (
<DropdownMenu>
@@ -55,8 +71,3 @@ export function LanguageSwitcher() {
</DropdownMenu>
);
}
+121 -58
View File
@@ -1,20 +1,10 @@
"use client";
import { useState } from "react";
import { Filter, Search, SortAsc, SortDesc, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Search,
Filter,
SortAsc,
SortDesc,
X,
Star,
TrendingUp,
Clock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,18 +13,22 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
interface MarketplaceFiltersProps {
onSearch: (query: string) => void;
onFilter: (filters: FilterState) => void;
onSort: (sortBy: string) => void;
totalItems: number;
showRarity?: boolean;
categoryOptionsOverride?: { value: string; label: string }[];
}
interface FilterState {
rarity: string[];
category: string[];
priceRange: { min: number; max: number };
vocation?: string | null;
}
export function MarketplaceFilters({
@@ -42,13 +36,16 @@ export function MarketplaceFilters({
onFilter,
onSort,
totalItems,
showRarity = true,
categoryOptionsOverride,
}: MarketplaceFiltersProps) {
const t = useTranslations("home");
const _t = useTranslations("home");
const [searchQuery, setSearchQuery] = useState("");
const [activeFilters, setActiveFilters] = useState<FilterState>({
rarity: [],
category: [],
priceRange: { min: 0, max: 100000 },
vocation: null,
});
const [sortBy, setSortBy] = useState("name");
@@ -60,7 +57,7 @@ export function MarketplaceFilters({
{ value: "legendary", label: "Lendário", color: "bg-orange-500" },
];
const categoryOptions = [
const categoryOptions = categoryOptionsOverride ?? [
{ value: "weapon", label: "Arma" },
{ value: "armor", label: "Armadura" },
{ value: "amulet", label: "Amuleto" },
@@ -70,13 +67,22 @@ export function MarketplaceFilters({
{ 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 = [
{ value: "name", label: "Nome", icon: SortAsc },
{ value: "price-asc", label: "Preço (Menor)", icon: SortAsc },
{ value: "price-desc", label: "Preço (Maior)", icon: SortDesc },
{ value: "rarity", label: "Raridade", icon: Star },
{ value: "trending", label: "Tendência", icon: TrendingUp },
{ value: "recent", label: "Recente", icon: Clock },
{ value: "name", label: "Nome (A→Z)", icon: SortAsc },
{ value: "level-asc", label: "Nível (Menor)", icon: SortAsc },
{ value: "level-desc", label: "Nível (Maior)", icon: SortDesc },
{ value: "weight-asc", label: "Peso (Leves)", icon: SortAsc },
{ value: "weight-desc", label: "Peso (Pesados)", icon: SortDesc },
{ value: "category", label: "Categoria (A→Z)", icon: SortAsc },
];
const handleSearch = (query: string) => {
@@ -114,18 +120,22 @@ export function MarketplaceFilters({
rarity: [],
category: [],
priceRange: { min: 0, max: 100000 },
vocation: null,
});
setSearchQuery("");
onFilter({
rarity: [],
category: [],
priceRange: { min: 0, max: 100000 },
vocation: null,
});
onSearch("");
};
const activeFiltersCount =
activeFilters.rarity.length + activeFilters.category.length;
activeFilters.rarity.length +
activeFilters.category.length +
(activeFilters.vocation ? 1 : 0);
return (
<div className="space-y-6">
@@ -142,37 +152,39 @@ export function MarketplaceFilters({
{/* Filtros e ordenação */}
<div className="flex flex-wrap items-center gap-4">
{/* Filtro por raridade */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<Filter className="h-4 w-4" />
Raridade
{activeFilters.rarity.length > 0 && (
<Badge variant="secondary" className="ml-1">
{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" />
{/* Filtro por raridade (opcional) */}
{showRarity && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<Filter className="h-4 w-4" />
Raridade
{activeFilters.rarity.length > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFilters.rarity.length}
</Badge>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</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>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Filtro por categoria */}
<DropdownMenu>
@@ -205,6 +217,47 @@ export function MarketplaceFilters({
</DropdownMenuContent>
</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 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -264,7 +317,7 @@ export function MarketplaceFilters({
})}
{activeFilters.category.map((category) => {
const option = categoryOptions.find(
(opt) => opt.value === category
(opt) => opt.value === category,
);
return (
<Badge
@@ -278,6 +331,21 @@ export function MarketplaceFilters({
</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>
)}
@@ -289,8 +357,3 @@ export function MarketplaceFilters({
</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>
);
}
+111
View File
@@ -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>
);
}
+408
View File
@@ -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>
);
}
+486
View File
@@ -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