16 Commits
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
276 changed files with 39610 additions and 1225 deletions

No files matched your search

+90 -43
View File
@@ -1,58 +1,105 @@
# Tibia Trade - Marketplace Seguro para Itens de Tibia <div align="center">
Marketplace multi-servidor seguro e confiável para compra, venda e negociação de itens de Tibia. Sistema de escrow protege 100% das transações. <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">
## 🎯 Características ![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)
- **Marketplace Multi-Servidor**: Funciona com qualquer servidor de Tibia [Plano de melhorias](docs/PLANO_MELHORIAS_MARKETPLACE.md) · [Configurar o AbacatePay](ABACATEPAY_SETUP.md) · [Issues](https://git.evandro.dev.br/evandro/pagodera/issues)
- **Sistema Escrow Seguro**: Protege 100% das transações contra scam
- **Múltiplos Idiomas**: Português e Inglês
- **Interface Moderna**: Design responsivo e acessível
- **Core Web Vitals Otimizados**: Performance máxima para SEO
## 🚀 Como Iniciar </div>
```bash 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:
# Instalar dependências
pnpm install
# Desenvolvimento ```console
pnpm dev $ node --no-warnings -e "const db = new (require('node:sqlite').DatabaseSync)('data/tibiawiki.db', { readOnly: true }); const t = db.prepare(\"select name from sqlite_master where type = 'table' and name not like 'sqlite_%'\").all(); const n = t.reduce((s, { name }) => s + db.prepare('select count(*) as c from ' + name).get().c, 0); console.log(t.length + ' categorias, ' + n + ' itens'); console.table(db.prepare('select nome, lvl, voc, peso from espadas order by lvl desc limit 3').all())"
28 categorias, 2575 itens
# Build para produção ┌─────────┬────────────────────────┬─────┬───────────┬──────┐
pnpm build │ (index) │ nome │ lvl │ voc │ peso │
├─────────┼────────────────────────┼─────┼───────────┼──────┤
# Iniciar servidor de produção │ 0 │ 'Grand Sanguine Blade' │ 600 │ 'Knights' │ 6000 │
pnpm start │ 1 │ 'Grand Sanguine Razor' │ 600 │ 'Knights' │ 7900 │
│ 2 │ 'Sanguine Blade' │ 600 │ 'Knights' │ 6100 │
└─────────┴────────────────────────┴─────┴───────────┴──────┘
``` ```
## 🛠️ Tecnologias ## O que é
- **Framework**: Next.js 15 (App Router) 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`).
- **Styling**: Tailwind CSS 4
- **UI Components**: Radix UI
- **Animações**: Motion (Framer Motion)
- **Internacionalização**: next-intl
- **Validação**: Zod
- **Typescript**: TypeScript 5
## 📦 Palavras-chave SEO | 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" |
- tibia marketplace ## Por que usar
- tibia trade
- comprar itens tibia
- vender itens tibia
- tibia escrow
- tibia gold
- tibia coins
- tc tibia
- marketplace tibia seguro
## 🌐 Domínios - **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.
- Desenvolvimento: https://tibiatrade.evandro.dev.br 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.
- Produção: https://tibiatrading.com (futuro)
## 📄 Licença ## Como funciona
Privado - Uso interno 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.
## Início rápido
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>
+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`);
}
+6 -6
View File
@@ -1,13 +1,13 @@
"use client"; "use client";
import { useEffect } from "react";
import { useParams } from "next/navigation";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import Header from "@/components/Header"; import { Heart, Home, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BlurFade } from "@/components/ui/blur-fade";
import { Heart, Share2, Home } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Header from "@/components/Header";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button";
export default function ThankYouPage() { export default function ThankYouPage() {
const params = useParams(); const params = useParams();
+10 -11
View File
@@ -1,20 +1,19 @@
import Header from "@/components/Header"; import { Heart } from "lucide-react";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { DonationCard } from "@/components/donation-card"; import { DonationCard } from "@/components/donation-card";
import { BlurFade } from "@/components/ui/blur-fade"; import Header from "@/components/Header";
import { NumberTicker } from "@/components/ui/number-ticker"; import { ShareButton } from "@/components/share-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Heart } from "lucide-react"; import { Badge } from "@/components/ui/badge";
import { getTranslations } from "next-intl/server"; import { BlurFade } from "@/components/ui/blur-fade";
import Link from "next/link"; import { Button } from "@/components/ui/button";
import { ShareButton } from "@/components/share-button"; import { NumberTicker } from "@/components/ui/number-ticker";
import type { Metadata } from "next";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -60,7 +59,7 @@ export default async function SupportPage({
}; };
// Custos mensais REAIS // Custos mensais REAIS
const monthlyCosts = { const _monthlyCosts = {
vps: 69, // R$ 69/mês vps: 69, // R$ 69/mês
devHours: 40, // média horas/mês devHours: 40, // média horas/mês
devRate: 50, // R$ 50/hora devRate: 50, // R$ 50/hora
+30 -40
View File
@@ -1,20 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono, Nunito_Sans } from "next/font/google"; import Script from "next/script";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server"; import { getMessages, setRequestLocale } from "next-intl/server";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import "../globals.css"; import { QueryProvider } from "@/lib/providers/query-provider";
const nunitoSans = Nunito_Sans({
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 async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -97,6 +86,13 @@ export async function generateMetadata({
images: ["/og-image.png"], images: ["/og-image.png"],
creator: "@tibiatrade", creator: "@tibiatrade",
}, },
icons: {
icon: [
{ url: "/favicon.ico", type: "image/x-icon" },
{ url: "/icon.svg", type: "image/svg+xml" },
],
apple: "/apple-touch-icon.png",
},
}; };
} }
@@ -108,20 +104,21 @@ export default async function LocaleLayout({
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
}) { }) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale);
const messages = await getMessages(); const messages = await getMessages();
const baseUrl = "https://tibiatrade.evandro.dev.br"; const baseUrl = "https://tibiatrade.evandro.dev.br";
return ( return (
<html lang={locale} suppressHydrationWarning> <QueryProvider>
<head> <NextIntlClientProvider messages={messages}>
<link rel="icon" href="/favicon.ico" sizes="any" /> <ThemeProvider attribute="class" defaultTheme="dark">
<link rel="icon" href="/icon.svg" type="image/svg+xml" /> <Script
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> id="structured-data-organization"
<script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ strategy="afterInteractive"
__html: JSON.stringify({ >
{JSON.stringify({
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Organization", "@type": "Organization",
name: "Tibia Trade", name: "Tibia Trade",
@@ -135,13 +132,14 @@ export default async function LocaleLayout({
contactType: "Customer Support", contactType: "Customer Support",
availableLanguage: ["Portuguese", "English"], availableLanguage: ["Portuguese", "English"],
}, },
}), })}
}} </Script>
/> <Script
<script id="structured-data-website"
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ strategy="afterInteractive"
__html: JSON.stringify({ >
{JSON.stringify({
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
name: "Tibia Trade", name: "Tibia Trade",
@@ -154,19 +152,11 @@ export default async function LocaleLayout({
}, },
"query-input": "required name=search_term_string", "query-input": "required name=search_term_string",
}, },
}), })}
}} </Script>
/> <div data-locale={locale}>{children}</div>
</head>
<body
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
>
<NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark">
{children}
</ThemeProvider> </ThemeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </QueryProvider>
</html>
); );
} }
+96 -21
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { MarketplaceFilters } from "@/components/marketplace-filters";
import { ItemCard } from "@/components/item-card"; import { ItemCard } from "@/components/item-card";
import { MarketplaceFilters } from "@/components/marketplace-filters";
interface Item { interface Item {
id: string | number; id: string | number;
@@ -38,7 +38,9 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
const [categories, setCategories] = useState<string[]>([]); const [categories, setCategories] = useState<string[]>([]);
const [rarities, setRarities] = useState<string[]>([]); const [rarities, setRarities] = useState<string[]>([]);
const [sort, setSort] = useState<string>("name"); const [sort, setSort] = useState<string>("name");
const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[] | undefined>(undefined); const [categoryOptions, setCategoryOptions] = useState<
{ value: string; label: string }[] | undefined
>(undefined);
const [vocation, setVocation] = useState<string | null>(null); const [vocation, setVocation] = useState<string | null>(null);
const debounceRef = useRef<number | undefined>(undefined); const debounceRef = useRef<number | undefined>(undefined);
@@ -77,7 +79,10 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
if (Array.isArray(data.categories)) { if (Array.isArray(data.categories)) {
const opts = (data.categories as string[]).map((c) => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) })); const opts = (data.categories as string[]).map((c) => ({
value: c,
label: c.charAt(0).toUpperCase() + c.slice(1),
}));
setCategoryOptions(opts); setCategoryOptions(opts);
} }
} catch { } catch {
@@ -90,7 +95,12 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
setPage(1); setPage(1);
setQuery(q); setQuery(q);
}; };
const onFilter = (f: { rarity: string[]; category: string[]; priceRange?: { min: number; max: number }; vocation?: string | null }) => { const onFilter = (f: {
rarity: string[];
category: string[];
priceRange?: { min: number; max: number };
vocation?: string | null;
}) => {
setPage(1); setPage(1);
setCategories(f.category || []); setCategories(f.category || []);
setRarities(f.rarity || []); setRarities(f.rarity || []);
@@ -103,10 +113,17 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<MarketplaceFilters onSearch={onSearch} onFilter={onFilter} onSort={onSort} totalItems={total} showRarity={false} categoryOptionsOverride={categoryOptions} /> <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"> <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) => { {items.map((item, idx) => {
const anyItem = item as any; const anyItem = item as any;
// Normalização de categoria para os ícones do ItemCard // Normalização de categoria para os ícones do ItemCard
const normalizeCategory = (c: string | undefined): string => { const normalizeCategory = (c: string | undefined): string => {
@@ -142,9 +159,31 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return map[v] || v || "other"; return map[v] || v || "other";
}; };
const categoryPt = typeof anyItem.categoria === "string" ? anyItem.categoria : undefined; const categoryPt =
const category = normalizeCategory(item.category ?? categoryPt); typeof anyItem.categoria === "string"
const name = item.name ?? anyItem.nome ?? String(item.id); ? 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 ?? ""; const image = item.image ?? anyItem.image_url ?? "";
// Campos numéricos normalizados // Campos numéricos normalizados
@@ -192,7 +231,8 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
const skill: string | undefined = const skill: string | undefined =
typeof item.skill === "string" && item.skill !== "Nenhum." typeof item.skill === "string" && item.skill !== "Nenhum."
? item.skill ? item.skill
: typeof anyItem.skill_para_usar === "string" && anyItem.skill_para_usar !== "Nenhum." : typeof anyItem.skill_para_usar === "string" &&
anyItem.skill_para_usar !== "Nenhum."
? anyItem.skill_para_usar ? anyItem.skill_para_usar
: undefined; : undefined;
const vocation: string | undefined = const vocation: string | undefined =
@@ -200,11 +240,31 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
? anyItem.voc ? anyItem.voc
: undefined; : 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 ( return (
<ItemCard <ItemCard
key={`${String((anyItem as any).category)}::${String(item.id)}::${name}`} key={safeKey}
item={{ item={
id: String(item.id), {
id: (idSafe ?? sourceTag ?? name ?? fallbackKey) as string,
name, name,
image, image,
category: category as any, category: category as any,
@@ -223,11 +283,24 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
vocation, vocation,
} as any, } as any,
// Campos específicos de aljavas (e reutilizáveis para outras categorias) // Campos específicos de aljavas (e reutilizáveis para outras categorias)
volume: typeof anyItem.volume === "number" ? anyItem.volume : undefined, volume:
bonus_de_skill: typeof anyItem.bonus_de_skill === "number" ? anyItem.bonus_de_skill : undefined, typeof anyItem.volume === "number"
protecao: typeof anyItem.protecao === "string" ? anyItem.protecao : undefined, ? anyItem.volume
dropa_de: typeof anyItem.dropa_de === "string" ? anyItem.dropa_de : undefined, : undefined,
} as any} 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" mode="catalog"
/> />
); );
@@ -236,14 +309,18 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button <button
type="button"
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50" className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1} disabled={page <= 1}
> >
Anterior Anterior
</button> </button>
<span className="text-sm text-muted-foreground">Página {page} de {totalPages}</span> <span className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</span>
<button <button
type="button"
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50" className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages} disabled={page >= totalPages}
@@ -254,5 +331,3 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
</div> </div>
); );
} }
+15 -7
View File
@@ -1,5 +1,5 @@
import Header from "@/components/Header";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import Header from "@/components/Header";
import LibraryClient from "./LibraryClient"; import LibraryClient from "./LibraryClient";
interface Item { interface Item {
@@ -18,17 +18,24 @@ interface Item {
async function fetchItems(params: URLSearchParams) { async function fetchItems(params: URLSearchParams) {
const qs = params.toString(); const qs = params.toString();
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`, { const res = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`,
{
// Em ambiente server, revalidação curta para manter leve // Em ambiente server, revalidação curta para manter leve
next: { revalidate: 15 }, next: { revalidate: 15 },
}); },
);
if (!res.ok) { if (!res.ok) {
return { items: [], total: 0, page: 1, pageSize: 24, totalPages: 1 }; return { items: [], total: 0, page: 1, pageSize: 24, totalPages: 1 };
} }
return res.json(); return res.json();
} }
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: "underConstruction" }); const t = await getTranslations({ locale, namespace: "underConstruction" });
return { return {
@@ -37,7 +44,10 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
}; };
} }
export default async function LibraryPage({ params, searchParams }: { export default async function LibraryPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>; searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
@@ -69,5 +79,3 @@ export default async function LibraryPage({ params, searchParams }: {
</div> </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>
);
}
+62 -11
View File
@@ -1,7 +1,7 @@
import Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Header from "@/components/Header";
import { getActiveListings } from "@/lib/prisma/queries/listings";
import MarketClient from "./MarketClient";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -9,7 +9,6 @@ export async function generateMetadata({
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: "underConstruction" });
const titles = { const titles = {
pt: "Mercado - Todos os Itens de Tibia", pt: "Mercado - Todos os Itens de Tibia",
@@ -37,23 +36,75 @@ export async function generateMetadata({
export default async function MarketPage({ export default async function MarketPage({
params, params,
searchParams,
}: { }: {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ const sp = (await searchParams) ?? {};
locale,
namespace: "underConstruction.market", const page = typeof sp.page === "string" ? parseInt(sp.page, 10) : 1;
const pageSize =
typeof sp.pageSize === "string" ? parseInt(sp.pageSize, 10) : 20;
// Buscar anúncios ativos
const listingsData = await getActiveListings({
search: typeof sp.search === "string" ? sp.search : undefined,
server_name:
typeof sp.server_name === "string" ? sp.server_name : undefined,
page,
pageSize,
sortBy: (typeof sp.sortBy === "string" ? sp.sortBy : "created_at") as
| "created_at"
| "price_pix"
| "price_tibia_coins"
| "price_gold_coins"
| "views_count",
sortOrder: (typeof sp.sortOrder === "string" ? sp.sortOrder : "DESC") as
| "ASC"
| "DESC",
}); });
// Converter Date para string para compatibilidade com o tipo do cliente
const serializedListings = listingsData.listings.map((listing) => ({
...listing,
created_at:
listing.created_at instanceof Date
? listing.created_at.toISOString()
: listing.created_at,
updated_at:
listing.updated_at instanceof Date
? listing.updated_at.toISOString()
: listing.updated_at,
sold_at:
listing.sold_at instanceof Date
? listing.sold_at.toISOString()
: listing.sold_at,
auction_ends_at:
listing.auction_ends_at instanceof Date
? listing.auction_ends_at.toISOString()
: listing.auction_ends_at,
expires_at:
listing.expires_at instanceof Date
? listing.expires_at.toISOString()
: listing.expires_at,
}));
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header /> <Header />
<UnderConstruction <main className="container mx-auto px-4 py-8">
title={t("title")} <MarketClient
description={t("description")} initial={{
locale={locale} listings: serializedListings as any,
total: listingsData.total,
page,
pageSize,
totalPages: Math.ceil(listingsData.total / pageSize),
}}
/> />
</main>
</div> </div>
); );
} }
+5 -5
View File
@@ -1,23 +1,23 @@
import { DonationFooterCard } from "@/components/donation-footer-card";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { EscrowBanner } from "@/components/sections/escrow-banner"; import { EscrowBanner } from "@/components/sections/escrow-banner";
import { EscrowExplanation } from "@/components/sections/escrow-explanation"; import { EscrowExplanation } from "@/components/sections/escrow-explanation";
import { FeaturedItems } from "@/components/sections/featured-items";
import { HeroSection } from "@/components/sections/hero-section"; import { HeroSection } from "@/components/sections/hero-section";
import { HowItWorks } from "@/components/sections/how-it-works"; import { HowItWorks } from "@/components/sections/how-it-works";
import { FeaturedItems } from "@/components/sections/featured-items"; import { MarketExample } from "@/components/sections/market-example";
import { MarketOffers } from "@/components/sections/market-offers";
import { MarketStats } from "@/components/sections/market-stats"; import { MarketStats } from "@/components/sections/market-stats";
import { DonationFooterCard } from "@/components/donation-footer-card";
export default function Home() { export default function Home() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header /> <Header />
<EscrowBanner /> <EscrowBanner />
<main role="main" aria-label="Página principal do Tibia Trade"> <main aria-label="Página principal do Tibia Trade">
<HeroSection /> <HeroSection />
<HowItWorks /> <HowItWorks />
<FeaturedItems /> <FeaturedItems />
<MarketOffers /> <MarketExample />
<MarketStats /> <MarketStats />
</main> </main>
<EscrowExplanation /> <EscrowExplanation />
+3 -3
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction"; import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -9,7 +9,7 @@ export async function generateMetadata({
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: "underConstruction" }); const _t = await getTranslations({ locale, namespace: "underConstruction" });
const titles = { const titles = {
pt: "Preços - Estrutura de Custos", pt: "Preços - Estrutura de Custos",
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction"; import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -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 { abacatePay } from "@/lib/abacatepay";
import { DONATION_PRODUCTS, DonorData } from "@/types/donation";
import { createDonationSchema } from "@/lib/validation-schemas";
import { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { createDonationSchema } from "@/lib/validation-schemas";
import { DONATION_PRODUCTS } from "@/types/donation";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos", error: "Dados de entrada inválidos",
details: errors, details: errors,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -72,8 +72,8 @@ export async function POST(request: NextRequest) {
if (validatedData.tier === "custom") { if (validatedData.tier === "custom") {
// Converter para centavos // Converter para centavos
finalAmount = Math.round(validatedData.customAmount! * 100); finalAmount = Math.round(validatedData.customAmount! * 100);
finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount!.toFixed( finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount?.toFixed(
2 2,
)}`; )}`;
secureLogger.info("💰 Valor customizado calculado", { secureLogger.info("💰 Valor customizado calculado", {
@@ -161,7 +161,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", error: error instanceof Error ? error.message : "Erro interno",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
+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 { getPaymentStatus } from "@/lib/payment-cache";
import { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
if (!pixId) { if (!pixId) {
return NextResponse.json( return NextResponse.json(
{ error: "pixId é obrigatório" }, { error: "pixId é obrigatório" },
{ status: 400 } { status: 400 },
); );
} }
@@ -38,7 +38,7 @@ export async function GET(request: NextRequest) {
console.error("Erro ao verificar status do pagamento:", error); console.error("Erro ao verificar status do pagamento:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao verificar status" }, { error: "Erro ao verificar status" },
{ status: 500 } { status: 500 },
); );
} }
} }
+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 { abacatePay } from "@/lib/abacatepay";
import { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
if (!billingId) { if (!billingId) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID da cobrança é obrigatório" }, { success: false, error: "ID da cobrança é obrigatório" },
{ status: 400 } { status: 400 },
); );
} }
@@ -42,7 +42,7 @@ export async function GET(request: NextRequest) {
error: error:
error instanceof Error ? error.message : "Erro ao verificar cobrança", error instanceof Error ? error.message : "Erro ao verificar cobrança",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
+107 -6
View File
@@ -1,14 +1,112 @@
import { NextRequest } from "next/server";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; 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) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const relPath = searchParams.get("path") || ""; 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 // Permitir apenas caminhos sob data/images para segurança
const baseDir = path.join(process.cwd(), "data", "images"); const baseDir = path.join(process.cwd(), "data", "images");
const resolved = path.join(process.cwd(), relPath); const resolved = path.join(process.cwd(), imagePath);
if (!resolved.startsWith(baseDir)) { if (!resolved.startsWith(baseDir)) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
@@ -26,10 +124,13 @@ export async function GET(req: NextRequest) {
: ext === ".jpg" || ext === ".jpeg" : ext === ".jpg" || ext === ".jpeg"
? "image/jpeg" ? "image/jpeg"
: "application/octet-stream"; : "application/octet-stream";
return new Response(file, { status: 200, headers: { "Content-Type": contentType } }); return new Response(file, {
} catch { 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 }); return new Response("Not Found", { status: 404 });
} }
} }
+13 -18
View File
@@ -1,25 +1,20 @@
import { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import path from "node:path"; import { prisma } from "@/lib/prisma/app";
function getDb() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Database = require("better-sqlite3");
const dbPath = path.join(process.cwd(), "data", "tibiawiki.db");
return new Database(dbPath, { readonly: true, fileMustExist: true });
}
export async function GET(_req: NextRequest) { export async function GET(_req: NextRequest) {
try { try {
const db = getDb(); const categories = await prisma.item.findMany({
const tables = db distinct: ["category"],
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence' ORDER BY name") select: { category: true },
.all() as { name: string }[]; orderBy: { category: "asc" },
db.close(); });
// Retorna nomes das tabelas (PT) para usar como categorias
return Response.json({ categories: tables.map((t) => t.name) }); return Response.json({
categories: categories
.map((row) => row.category)
.filter((category): category is string => Boolean(category)),
});
} catch (e: any) { } catch (e: any) {
return Response.json({ error: e?.message || "Erro" }, { status: 500 }); return Response.json({ error: e?.message || "Erro" }, { status: 500 });
} }
} }
+63 -31
View File
@@ -1,61 +1,95 @@
import { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { searchItems } from "@/lib/items-db";
import { buildPublicItemImageUrl } from "@/lib/item-images"; import { buildPublicItemImageUrl } from "@/lib/item-images";
import { type ItemSort, searchItems } from "@/lib/prisma/queries/items";
function parseArray(param: string | null): string[] { function parseArray(param: string | null): string[] {
if (!param) return []; if (!param) return [];
// Suporta formatos: comma-separated (a,b) ou múltiplos parâmetros já combinados
return param return param
.split(",") .split(",")
.map((v) => v.trim()) .map((value) => value.trim())
.filter(Boolean); .filter(Boolean);
} }
const allowedSorts: ItemSort[] = [
"name",
"level-asc",
"level-desc",
"weight-asc",
"weight-desc",
"category",
"updated-desc",
];
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const q = searchParams.get("q") || undefined; const q = searchParams.get("q") || undefined;
const categories = parseArray(searchParams.get("category")); const categories = parseArray(searchParams.get("category"));
const rarities = parseArray(searchParams.get("rarity")); const rarities = parseArray(searchParams.get("rarity"));
const sortParam = (searchParams.get("sort") || "name") as const server = searchParams.get("server") || undefined;
| "name" const sortParamRaw = (searchParams.get("sort") || "name").toLowerCase();
| "level-asc" const sortParam = (allowedSorts.find((sort) => sort === sortParamRaw) ||
| "level-desc" "name") as ItemSort;
| "weight-asc"
| "weight-desc"
| "category";
const page = Number(searchParams.get("page") || "1"); const page = Number(searchParams.get("page") || "1");
const pageSize = Number(searchParams.get("pageSize") || "24"); const pageSize = Number(searchParams.get("pageSize") || "24");
const voc = searchParams.get("voc") || undefined;
const { items, total } = searchItems({ const { items, total } = await searchItems({
q, q,
categories, category: categories[0],
rarities, rarity: rarities[0],
server,
sort: sortParam, sort: sortParam,
vocation: voc,
page, page,
pageSize, pageSize,
}); });
// Enriquecer com URL pública de imagem quando possível // Normaliza campos esperados pelo cliente, preservando compatibilidade
const itemsWithImage = items.map((it: any) => { const itemsWithImage = items.map((item) => {
const img = (it.image || "").toString(); const imgRaw = (item.image_url || item.image_local_path || "").toString();
// Preferir URL absoluta do banco para teste const categorySafe = item.category || "other";
if (/^https?:\/\//i.test(img)) {
return { ...it, image: img }; 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 (img.startsWith("data/")) { if (imgRaw.startsWith("data/")) {
return { ...it, image: `/api/images?path=${encodeURIComponent(img)}` }; return {
...base,
image_url: imgRaw,
image: `/api/images?path=${encodeURIComponent(imgRaw)}`,
};
} }
const categoryEn = (it.category || "other").toString(); return {
return { ...it, image: buildPublicItemImageUrl(categoryEn, img) }; ...base,
image_url: item.image_url,
image: buildPublicItemImageUrl(categorySafe, imgRaw),
};
}); });
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
return Response.json( return Response.json(
{ items: itemsWithImage, page, pageSize, total, totalPages }, {
{ status: 200 } items: itemsWithImage,
page,
pageSize,
total,
totalPages,
sort: sortParam,
},
{ status: 200 },
); );
} catch (error: any) { } catch (error: any) {
console.error("[api/items] Erro:", error?.message, error?.stack); console.error("[api/items] Erro:", error?.message, error?.stack);
@@ -64,9 +98,7 @@ export async function GET(req: NextRequest) {
error: "Falha ao consultar itens", error: "Falha ao consultar itens",
message: error?.message || "Erro desconhecido", message: error?.message || "Erro desconhecido",
}, },
{ status: 500 } { 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 { type NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay";
import { markAsPaid } from "@/lib/payment-cache"; import { markAsPaid } from "@/lib/payment-cache";
import { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
@@ -56,11 +55,11 @@ export async function POST(request: NextRequest) {
// Rejeitar eventos com mais de 5 minutos de diferença // Rejeitar eventos com mais de 5 minutos de diferença
if (timeDiff > 5 * 60 * 1000) { if (timeDiff > 5 * 60 * 1000) {
secureLogger.warn("Webhook rejeitado: timestamp inválido", { secureLogger.warn("Webhook rejeitado: timestamp inválido", {
timeDiff: timeDiff / 1000 + "s", timeDiff: `${timeDiff / 1000}s`,
}); });
return NextResponse.json( return NextResponse.json(
{ error: "Timestamp inválido" }, { error: "Timestamp inválido" },
{ status: 400 } { status: 400 },
); );
} }
} }
@@ -119,7 +118,7 @@ export async function POST(request: NextRequest) {
secureLogger.info( secureLogger.info(
"Registro de pagamento criado", "Registro de pagamento criado",
secureLogger.mask(donationRecord) secureLogger.mask(donationRecord),
); );
// Salvar no cache para frontend detectar via polling // Salvar no cache para frontend detectar via polling
@@ -150,7 +149,7 @@ export async function POST(request: NextRequest) {
secureLogger.error("Webhook error", error); secureLogger.error("Webhook error", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao processar webhook" }, { error: "Erro ao processar webhook" },
{ status: 500 } { status: 500 },
); );
} }
} }
+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>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://tibiatrade.evandro.dev.br"; const baseUrl = "https://tibiatrade.evandro.dev.br";
+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

+134 -8
View File
@@ -1,5 +1,20 @@
"use client"; "use client";
import { LogOut, User } from "lucide-react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
NavigationMenu, NavigationMenu,
NavigationMenuItem, NavigationMenuItem,
@@ -8,16 +23,28 @@ import {
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"; } from "@/components/ui/navigation-menu";
import { WordRotate } from "@/components/ui/word-rotate"; import { WordRotate } from "@/components/ui/word-rotate";
import { ThemeToggle } from "./theme-toggle"; import { useSession } from "@/lib/hooks/use-session";
import { LanguageSwitcher } from "./language-switcher";
import { MobileMenu } from "./mobile-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslations, useLocale } from "next-intl"; import { LoginDialog } from "./auth/login-dialog";
import Link from "next/link"; import { RegisterDialog } from "./auth/register-dialog";
import { LanguageSwitcher } from "./language-switcher";
import { MessagesBell } from "./messages/MessagesBell";
import { MobileMenu } from "./mobile-menu";
import { NotificationBell } from "./notifications/NotificationBell";
import { ThemeToggle } from "./theme-toggle";
export default function Header() { export default function Header() {
const t = useTranslations("header.nav"); const t = useTranslations("header.nav");
const locale = useLocale(); const locale = useLocale();
const { user, isAuthenticated, isLoading, logout } = useSession();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [mounted, setMounted] = useState(false);
// Evitar hidratação mismatch
useEffect(() => {
setMounted(true);
}, []);
const links = [ const links = [
{ href: `/${locale}/library`, label: "Library" }, { href: `/${locale}/library`, label: "Library" },
@@ -27,6 +54,17 @@ export default function Header() {
{ href: `/${locale}/apoiar`, label: t("support"), highlight: true }, { href: `/${locale}/apoiar`, label: t("support"), highlight: true },
]; ];
const navLinks = [
...links,
...(isAuthenticated && user?.userId === 1
? [{ href: `/${locale}/admin`, label: "Admin" }]
: []),
];
const handleLogout = async () => {
await logout();
};
return ( return (
<header className="sticky top-0 z-50 w-full border-b bg-background/60 backdrop-blur supports-backdrop-filter:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b bg-background/60 backdrop-blur supports-backdrop-filter:bg-background/60">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4"> <div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4">
@@ -43,7 +81,7 @@ export default function Header() {
{/* Desktop Navigation */} {/* Desktop Navigation */}
<NavigationMenu className="hidden md:flex"> <NavigationMenu className="hidden md:flex">
<NavigationMenuList> <NavigationMenuList>
{links.map(({ href, label, highlight }) => ( {navLinks.map(({ href, label, highlight }) => (
<NavigationMenuItem key={href}> <NavigationMenuItem key={href}>
<NavigationMenuLink asChild> <NavigationMenuLink asChild>
<Link <Link
@@ -52,7 +90,7 @@ export default function Header() {
navigationMenuTriggerStyle(), navigationMenuTriggerStyle(),
"bg-transparent", "bg-transparent",
highlight && highlight &&
"bg-primary text-primary-foreground hover:bg-primary/90" "bg-primary text-primary-foreground hover:bg-primary/90",
)} )}
> >
{label} {label}
@@ -67,13 +105,101 @@ export default function Header() {
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
<ThemeToggle /> <ThemeToggle />
<LanguageSwitcher /> <LanguageSwitcher />
{/* Messages */}
{mounted && isAuthenticated && <MessagesBell />}
{/* Notifications */}
{mounted && isAuthenticated && <NotificationBell />}
{mounted && isAuthenticated && user?.subscriptionTier && (
<Badge variant="outline" className="uppercase">
{user.subscriptionTier.slug}
</Badge>
)}
{/* Auth Section */}
{!mounted ? (
// Placeholder durante SSR para evitar mismatch
<div className="w-20 h-8 animate-pulse bg-muted rounded" />
) : isLoading ? (
<div className="w-20 h-8 animate-pulse bg-muted rounded" />
) : isAuthenticated && user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="relative h-8 w-8 rounded-full"
>
<Avatar className="h-8 w-8">
<AvatarFallback>
{user.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.username}
</p>
{user.characterName && (
<p className="text-xs leading-none text-primary font-medium">
{user.characterName}
</p>
)}
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/${locale}/perfil`} className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
Perfil
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-destructive"
onClick={handleLogout}
>
<LogOut className="mr-2 h-4 w-4" />
Sair
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setRegisterDialogOpen(true)}
size="sm"
>
Registrar
</Button>
<Button onClick={() => setLoginDialogOpen(true)} size="sm">
Entrar
</Button>
</div>
)}
</div> </div>
{/* Mobile Menu */} {/* Mobile Menu */}
<div className="md:hidden"> <div className="md:hidden">
<MobileMenu links={links} /> <MobileMenu links={navLinks} />
</div> </div>
</div> </div>
{/* Login Dialog */}
<LoginDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen} />
{/* Register Dialog */}
<RegisterDialog
open={registerDialogOpen}
onOpenChange={setRegisterDialogOpen}
/>
</header> </header>
); );
} }
+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>
);
}
+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"; "use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { import {
Heart,
Coffee,
Pizza,
Loader2,
Copy,
Check, Check,
Lock,
User,
CheckCircle, CheckCircle,
CheckCircle2, CheckCircle2,
DollarSign, Coffee,
Copy,
Heart,
Loader2,
Lock,
Pizza,
User,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DonationTier, DonationResponse } from "@/types/donation"; import { Input } from "@/components/ui/input";
import { useTranslations } from "next-intl"; import { Textarea } from "@/components/ui/textarea";
import type { DonationResponse, DonationTier } from "@/types/donation";
interface DonationCardProps extends DonationTier {} interface DonationCardProps extends DonationTier {}
@@ -61,7 +60,7 @@ export function DonationCard({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<string | null>(null); const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
const [paymentConfirmed, setPaymentConfirmed] = useState(false); const [_paymentConfirmed, setPaymentConfirmed] = useState(false);
const [showThankYouModal, setShowThankYouModal] = useState(false); const [showThankYouModal, setShowThankYouModal] = useState(false);
const t = useTranslations("donation"); const t = useTranslations("donation");
@@ -94,7 +93,7 @@ export function DonationCard({
const updateTimer = () => { const updateTimer = () => {
try { try {
const expires = new Date(pixData!.expiresAt!); const expires = new Date(pixData?.expiresAt!);
const now = new Date(); const now = new Date();
const diff = expires.getTime() - now.getTime(); const diff = expires.getTime() - now.getTime();
@@ -118,7 +117,7 @@ export function DonationCard({
try { try {
const response = await fetch( const response = await fetch(
`/api/donations/status?pixId=${pixData.pixId}` `/api/donations/status?pixId=${pixData.pixId}`,
); );
const data = await response.json(); const data = await response.json();
@@ -346,7 +345,7 @@ export function DonationCard({
const isValidCustomAmount = () => { const isValidCustomAmount = () => {
const amount = parseFloat(customAmount); const amount = parseFloat(customAmount);
return ( return (
!isNaN(amount) && !Number.isNaN(amount) &&
amount >= customProduct.minAmount && amount >= customProduct.minAmount &&
amount <= customProduct.maxAmount amount <= customProduct.maxAmount
); );
@@ -363,12 +362,12 @@ export function DonationCard({
// Garante apenas um separador decimal // Garante apenas um separador decimal
const parts = normalized.split("."); const parts = normalized.split(".");
if (parts.length > 2) { if (parts.length > 2) {
return parts[0] + "." + parts.slice(1).join(""); return `${parts[0]}.${parts.slice(1).join("")}`;
} }
// Limita 2 casas decimais // Limita 2 casas decimais
if (parts[1]?.length > 2) { if (parts[1]?.length > 2) {
return parts[0] + "." + parts[1].substring(0, 2); return `${parts[0]}.${parts[1].substring(0, 2)}`;
} }
return normalized; return normalized;
@@ -396,12 +395,12 @@ export function DonationCard({
setError("Digite um valor"); setError("Digite um valor");
return; return;
} }
const amount = parseFloat(customAmount); const _amount = parseFloat(customAmount);
if (!isValidCustomAmount()) { if (!isValidCustomAmount()) {
setError( setError(
`Valor deve estar entre R$ ${customProduct.minAmount.toFixed( `Valor deve estar entre R$ ${customProduct.minAmount.toFixed(
2 2,
)} e R$ ${customProduct.maxAmount.toFixed(2)}` )} e R$ ${customProduct.maxAmount.toFixed(2)}`,
); );
return; return;
} }
@@ -463,10 +462,10 @@ export function DonationCard({
<p className="text-xs text-red-500"> <p className="text-xs text-red-500">
{parseFloat(customAmount) < customProduct.minAmount {parseFloat(customAmount) < customProduct.minAmount
? `${t( ? `${t(
"form.minAmount" "form.minAmount",
)} ${customProduct.minAmount.toFixed(2)}` )} ${customProduct.minAmount.toFixed(2)}`
: `${t( : `${t(
"form.maxAmount" "form.maxAmount",
)} ${customProduct.maxAmount.toFixed(2)}`} )} ${customProduct.maxAmount.toFixed(2)}`}
</p> </p>
)} )}
@@ -485,7 +484,7 @@ export function DonationCard({
</> </>
) : customAmount ? ( ) : customAmount ? (
`${t("form.donate")} ${parseFloat( `${t("form.donate")} ${parseFloat(
customAmount || "0" customAmount || "0",
).toFixed(2)}` ).toFixed(2)}`
) : ( ) : (
t("form.chooseAmount") t("form.chooseAmount")
+4 -4
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Coffee, Heart } from "lucide-react";
import { Heart, Coffee } from "lucide-react";
import { BlurFade } from "@/components/ui/blur-fade";
import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button";
export function DonationFooterCard() { export function DonationFooterCard() {
const t = useTranslations("donation"); const t = useTranslations("donation");
+96 -83
View File
@@ -1,44 +1,39 @@
"use client"; "use client";
import { TibiaItem } from "@/types/item";
import { MagicCard } from "@/components/ui/magic-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PriceDisplay } from "@/components/ui/price-display";
import { useTranslations } from "next-intl";
import { import {
TrendingUp, ArrowDown,
TrendingDown, ArrowUp,
Sword, Boxes,
Shield,
Weight,
Heart,
Star,
Clock, Clock,
Coins, Coins,
ArrowUp,
ArrowDown,
Minus,
Zap,
Flame,
Snowflake,
Zap as Energy,
Skull,
Cross, Cross,
Badge as BadgeIcon, Zap as Energy,
Boxes, Flame,
Gem,
FlaskConical, FlaskConical,
Wrench, Gem,
ScrollText, Heart,
Leaf, Leaf,
Minus,
ScrollText,
Shield,
Skull,
Snowflake,
Star,
Sword,
TrendingDown,
TrendingUp,
Weight,
Wrench,
Zap,
} from "lucide-react"; } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { Badge } from "@/components/ui/badge";
getRarityColor, import { Button } from "@/components/ui/button";
getRarityBgColor, import { MagicCard } from "@/components/ui/magic-card";
getRarityGlow, import { PriceDisplay } from "@/components/ui/price-display";
} from "@/lib/currency-utils"; import { formatNumber } from "@/lib/utils/formatters";
import type { TibiaItem } from "@/types/item";
interface ItemCardProps { interface ItemCardProps {
item: TibiaItem; item: TibiaItem;
@@ -47,8 +42,9 @@ interface ItemCardProps {
export function ItemCard({ item, mode = "market" }: ItemCardProps) { export function ItemCard({ item, mode = "market" }: ItemCardProps) {
const t = useTranslations("home.item"); const t = useTranslations("home.item");
const locale = useLocale();
const [isFavorited, setIsFavorited] = useState(false); const [isFavorited, setIsFavorited] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [_isHovered, setIsHovered] = useState(false);
const formatVocation = (v: string | undefined): string | undefined => { const formatVocation = (v: string | undefined): string | undefined => {
if (!v) return undefined; if (!v) return undefined;
let out = String(v); let out = String(v);
@@ -62,14 +58,22 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
return out; return out;
}; };
const ScrollRow = ({ children, ariaLabel }: { children: React.ReactNode; ariaLabel: string }) => { const ScrollRow = ({
children,
ariaLabel,
}: {
children: React.ReactNode;
ariaLabel: string;
}) => {
const scrollerRef = useRef<HTMLDivElement | null>(null); const scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false); const [_canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false); const [_canScrollRight, setCanScrollRight] = useState(false);
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
const autoScrollRef = useRef<number | null>(null); const autoScrollRef = useRef<number | null>(null);
const drag = useRef<{ active: boolean; startX: number; startLeft: number }>({ active: false, startX: 0, startLeft: 0 }); const drag = useRef<{ active: boolean; startX: number; startLeft: number }>(
{ active: false, startX: 0, startLeft: 0 },
);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const updateButtons = () => { const updateButtons = () => {
@@ -112,9 +116,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
el.removeEventListener("mouseleave", onLeave); el.removeEventListener("mouseleave", onLeave);
ro.disconnect(); ro.disconnect();
}; };
}, []); }, [updateButtons]);
const scrollBy = (dx: number) => { const _scrollBy = (dx: number) => {
const el = scrollerRef.current; const el = scrollerRef.current;
if (!el) return; if (!el) return;
const base = contentRef.current?.offsetWidth || 0; const base = contentRef.current?.offsetWidth || 0;
@@ -180,7 +184,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
if (!drag.current.active) return; if (!drag.current.active) return;
drag.current.active = false; drag.current.active = false;
setIsDragging(false); setIsDragging(false);
try { el.releasePointerCapture(e.pointerId); } catch {} try {
el.releasePointerCapture(e.pointerId);
} catch {}
}; };
el.addEventListener("pointerdown", onPointerDown); el.addEventListener("pointerdown", onPointerDown);
@@ -212,9 +218,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
); );
}; };
const [showAllDroppers, setShowAllDroppers] = useState(false); const [_showAllDroppers, _setShowAllDroppers] = useState(false);
const rarityColors = { const _rarityColors = {
common: "bg-gray-500", common: "bg-gray-500",
uncommon: "bg-green-500", uncommon: "bg-green-500",
rare: "bg-blue-500", rare: "bg-blue-500",
@@ -249,14 +255,20 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
(item as any).price.sell !== 0; (item as any).price.sell !== 0;
const priceDifference = hasPrice const priceDifference = hasPrice
? (((item as any).price.buy - (item as any).price.sell) / ? (((item as any).price.buy - (item as any).price.sell) /
(item as any).price.sell) * 100 (item as any).price.sell) *
100
: 0; : 0;
const isHotDeal = hasPrice && priceDifference > 20; // Hot deal se diferença > 20% const isHotDeal = hasPrice && priceDifference > 20; // Hot deal se diferença > 20%
// Determinar tendência de preço // Determinar tendência de preço
const getPriceTrend = () => { const getPriceTrend = () => {
const ph = (item as any).priceHistory; const ph = (item as any).priceHistory;
if (!ph || typeof ph.highest !== "number" || typeof ph.lowest !== "number" || typeof ph.average !== "number") { if (
!ph ||
typeof ph.highest !== "number" ||
typeof ph.lowest !== "number" ||
typeof ph.average !== "number"
) {
return "stable"; return "stable";
} }
if (ph.highest > ph.average * 1.1) return "up"; if (ph.highest > ph.average * 1.1) return "up";
@@ -283,21 +295,6 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<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"> <MagicCard className="p-0 overflow-hidden rounded-xl border border-border/50 hover:border-primary/30 transition-all duration-300 hover:scale-[1.02] hover:shadow-xl">
{/* Header com ícone e badge de raridade */} {/* Header com ícone e badge de raridade */}
<div className="relative aspect-square bg-linear-to-br from-background to-muted p-6"> <div className="relative aspect-square bg-linear-to-br from-background to-muted p-6">
{/* Badge de raridade com glow (apenas em modo market) */}
{mode === "market" && (
<Badge
className={`${
rarityColors[item.rarity]
} text-white absolute top-3 left-3 z-20 shadow-lg ${getRarityGlow(
item.rarity
)} ${item.rarity === "legendary" ? "animate-pulse" : ""} ${
item.rarity === "epic" ? "animate-pulse" : ""
}`}
>
{item.rarity}
</Badge>
)}
{/* Botão de favorito */} {/* Botão de favorito */}
<Button <Button
variant="ghost" variant="ghost"
@@ -336,9 +333,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
/> />
) : ( ) : (
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm"> <div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
{typeof CategoryIcon === "string" ? ( {typeof CategoryIcon === "string" ? null : (
null
) : (
<CategoryIcon className="w-10 h-10" /> <CategoryIcon className="w-10 h-10" />
)} )}
</div> </div>
@@ -365,7 +360,8 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
{(() => { {(() => {
const catPt = (item as any).categoryPt as string | undefined; const catPt = (item as any).categoryPt as string | undefined;
const catEn = (item as any).category as string | undefined; const catEn = (item as any).category as string | undefined;
const catRaw = (catPt && String(catPt)) || (catEn && String(catEn)) || ""; const catRaw =
(catPt && String(catPt)) || (catEn && String(catEn)) || "";
const display = catRaw const display = catRaw
? catRaw.charAt(0).toUpperCase() + catRaw.slice(1) ? catRaw.charAt(0).toUpperCase() + catRaw.slice(1)
: ""; : "";
@@ -388,9 +384,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
{item.stats.attack && ( {item.stats.attack && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Sword className="h-4 w-4 text-primary" /> <Sword className="h-4 w-4 text-primary" />
<span className="font-medium"> <span className="font-medium">{item.stats.attack} ATK</span>
{item.stats.attack} ATK
</span>
</div> </div>
)} )}
{item.stats.defense && ( {item.stats.defense && (
@@ -404,21 +398,23 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
{item.stats.armor && ( {item.stats.armor && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Shield className="h-4 w-4 text-primary" /> <Shield className="h-4 w-4 text-primary" />
<span className="font-medium"> <span className="font-medium">{item.stats.armor} ARM</span>
{item.stats.armor} ARM
</span>
</div> </div>
)} )}
{(item as any).volume && ( {(item as any).volume && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Boxes className="h-4 w-4 text-primary" /> <Boxes className="h-4 w-4 text-primary" />
<span className="font-medium">Volume {(item as any).volume}</span> <span className="font-medium">
Volume {(item as any).volume}
</span>
</div> </div>
)} )}
{(item as any).bonus_de_skill && ( {(item as any).bonus_de_skill && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Zap className="h-4 w-4 text-primary" /> <Zap className="h-4 w-4 text-primary" />
<span className="font-medium">Skill +{(item as any).bonus_de_skill}</span> <span className="font-medium">
Skill +{(item as any).bonus_de_skill}
</span>
</div> </div>
)} )}
</div> </div>
@@ -470,7 +466,8 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</div> </div>
)} )}
{(item as any).protecao && (() => { {(item as any).protecao &&
(() => {
const raw = String((item as any).protecao || ""); const raw = String((item as any).protecao || "");
const tokens = raw const tokens = raw
.split(",") .split(",")
@@ -482,7 +479,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<ScrollRow ariaLabel="elemental protections"> <ScrollRow ariaLabel="elemental protections">
{tokens.map((raw: string, idx: number) => { {tokens.map((raw: string, idx: number) => {
const token = raw.trim(); const token = raw.trim();
const match = token.match(/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/); const match = token.match(
/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/,
);
const type = (match?.[1] || token).trim().toLowerCase(); const type = (match?.[1] || token).trim().toLowerCase();
const value = (match?.[2] || "").trim(); const value = (match?.[2] || "").trim();
let Icon: any = Shield; let Icon: any = Shield;
@@ -536,10 +535,14 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
chipBg = "bg-amber-500/10"; chipBg = "bg-amber-500/10";
chipText = "text-amber-700"; chipText = "text-amber-700";
} }
const prettyType = type.charAt(0).toUpperCase() + type.slice(1); const prettyType =
const label = `${prettyType.replace("fisico", "Físico")}${value ? " " + value : ""}`; type.charAt(0).toUpperCase() + type.slice(1);
const label = `${prettyType.replace("fisico", "Físico")}${value ? ` ${value}` : ""}`;
return ( return (
<div key={`${type}-${idx}`} className={`flex items-center gap-1 text-xs ${chipBg} ${chipText} px-2 py-1 rounded`}> <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" /> <Icon className="h-3 w-3" />
<span className="whitespace-nowrap">{label}</span> <span className="whitespace-nowrap">{label}</span>
</div> </div>
@@ -565,7 +568,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</div> </div>
)} )}
{(item as any).dropa_de && ( {(item as any).dropa_de &&
(() => { (() => {
const raw = String((item as any).dropa_de || ""); const raw = String((item as any).dropa_de || "");
const normalized = raw.trim(); const normalized = raw.trim();
@@ -601,8 +604,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</ScrollRow> </ScrollRow>
</div> </div>
); );
})() })()}
)}
{mode === "market" && item.price && ( {mode === "market" && item.price && (
<> <>
@@ -662,7 +664,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{t("priceHistory")} {t("priceHistory")}
</span> </span>
<div className={`flex items-center gap-1 text-xs ${trendColor}`}> <div
className={`flex items-center gap-1 text-xs ${trendColor}`}
>
<TrendIcon className="h-3 w-3" /> <TrendIcon className="h-3 w-3" />
<span className="capitalize">{trend}</span> <span className="capitalize">{trend}</span>
</div> </div>
@@ -674,21 +678,27 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<TrendingUp className="h-3 w-3" /> <TrendingUp className="h-3 w-3" />
<span className="font-medium">{t("highest")}</span> <span className="font-medium">{t("highest")}</span>
</div> </div>
<div className="font-bold">{item.priceHistory.highest.toLocaleString()} gp</div> <div className="font-bold">
{formatNumber(item.priceHistory.highest, locale)} gp
</div>
</div> </div>
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-red-600"> <div className="flex items-center justify-center gap-1 text-red-600">
<TrendingDown className="h-3 w-3" /> <TrendingDown className="h-3 w-3" />
<span className="font-medium">{t("lowest")}</span> <span className="font-medium">{t("lowest")}</span>
</div> </div>
<div className="font-bold">{item.priceHistory.lowest.toLocaleString()} gp</div> <div className="font-bold">
{formatNumber(item.priceHistory.lowest, locale)} gp
</div>
</div> </div>
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-muted-foreground"> <div className="flex items-center justify-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" /> <Minus className="h-3 w-3" />
<span className="font-medium">{t("average")}</span> <span className="font-medium">{t("average")}</span>
</div> </div>
<div className="font-bold">{item.priceHistory.average.toLocaleString()} gp</div> <div className="font-bold">
{formatNumber(item.priceHistory.average, locale)} gp
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -703,7 +713,10 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
> >
{t("viewDetails")} {t("viewDetails")}
</Button> </Button>
<Button className="w-full bg-green-600 hover:bg-green-700 text-white" size="sm"> <Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
size="sm"
>
{t("buyNow")} {t("buyNow")}
</Button> </Button>
</div> </div>
+19 -8
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { Globe } from "lucide-react"; import { Globe } from "lucide-react";
import { useLocale } from "next-intl";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -10,7 +11,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useTranslations } from "next-intl";
const languages = [ const languages = [
{ code: "pt", name: "Português", flag: "🇧🇷" }, { code: "pt", name: "Português", flag: "🇧🇷" },
@@ -22,6 +22,12 @@ export function LanguageSwitcher() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const t = useTranslations("header"); const t = useTranslations("header");
const [mounted, setMounted] = useState(false);
// Evitar hydration mismatch com Radix UI
useEffect(() => {
setMounted(true);
}, []);
const handleLanguageChange = (newLocale: string) => { const handleLanguageChange = (newLocale: string) => {
// Remove the current locale from the pathname // Remove the current locale from the pathname
@@ -30,7 +36,17 @@ export function LanguageSwitcher() {
router.push(`/${newLocale}${pathWithoutLocale}`); router.push(`/${newLocale}${pathWithoutLocale}`);
}; };
const currentLanguage = languages.find((lang) => lang.code === locale); const _currentLanguage = languages.find((lang) => lang.code === locale);
// Renderizar placeholder durante SSR para evitar mismatch
if (!mounted) {
return (
<Button variant="outline" size="icon" disabled>
<Globe className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">{t("language")}</span>
</Button>
);
}
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -55,8 +71,3 @@ export function LanguageSwitcher() {
</DropdownMenu> </DropdownMenu>
); );
} }
+12 -22
View File
@@ -1,20 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { Filter, Search, SortAsc, SortDesc, X } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Button } from "@/components/ui/button";
Search,
Filter,
SortAsc,
SortDesc,
X,
Star,
TrendingUp,
Clock,
} from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -23,6 +13,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
interface MarketplaceFiltersProps { interface MarketplaceFiltersProps {
onSearch: (query: string) => void; onSearch: (query: string) => void;
@@ -48,7 +39,7 @@ export function MarketplaceFilters({
showRarity = true, showRarity = true,
categoryOptionsOverride, categoryOptionsOverride,
}: MarketplaceFiltersProps) { }: MarketplaceFiltersProps) {
const t = useTranslations("home"); const _t = useTranslations("home");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeFilters, setActiveFilters] = useState<FilterState>({ const [activeFilters, setActiveFilters] = useState<FilterState>({
rarity: [], rarity: [],
@@ -142,7 +133,9 @@ export function MarketplaceFilters({
}; };
const activeFiltersCount = const activeFiltersCount =
activeFilters.rarity.length + activeFilters.category.length + (activeFilters.vocation ? 1 : 0); activeFilters.rarity.length +
activeFilters.category.length +
(activeFilters.vocation ? 1 : 0);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -247,7 +240,9 @@ export function MarketplaceFilters({
const newFilters = { const newFilters = {
...activeFilters, ...activeFilters,
vocation: vocation:
activeFilters.vocation === option.value ? null : option.value, activeFilters.vocation === option.value
? null
: option.value,
}; };
setActiveFilters(newFilters); setActiveFilters(newFilters);
onFilter(newFilters); onFilter(newFilters);
@@ -322,7 +317,7 @@ export function MarketplaceFilters({
})} })}
{activeFilters.category.map((category) => { {activeFilters.category.map((category) => {
const option = categoryOptions.find( const option = categoryOptions.find(
(opt) => opt.value === category (opt) => opt.value === category,
); );
return ( return (
<Badge <Badge
@@ -362,8 +357,3 @@ export function MarketplaceFilters({
</div> </div>
); );
} }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,275 @@
"use client";
import {
Clock,
Eye,
Heart,
MessageSquare,
MousePointerClick,
TrendingUp,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useSession } from "@/lib/hooks/use-session";
interface AdAnalytics {
ad_id: number;
impressions: number;
clicks: number;
ctr: number;
avg_view_duration: number;
bounce_rate: number;
inquiries: number;
favorites: number;
conversion_rate: number;
time_to_sell: number | null;
last_updated: Date;
}
interface Listing {
listing_id: number;
item_name: string;
listing_status: string;
}
export function AnalyticsDashboard() {
const { user } = useSession();
const [listings, setListings] = useState<Listing[]>([]);
const [selectedAd, setSelectedAd] = useState<number | null>(null);
const [analytics, setAnalytics] = useState<AdAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const loadUserListings = async () => {
setLoading(true);
try {
const res = await fetch("/api/listings/my");
if (res.ok) {
const data = await res.json();
setListings(data.listings || []);
if (data.listings && data.listings.length > 0) {
setSelectedAd(data.listings[0].listing_id);
}
}
} catch (error) {
console.error("Erro ao carregar anúncios:", error);
} finally {
setLoading(false);
}
};
const loadAnalytics = async (adId: number) => {
try {
const res = await fetch(`/api/telemetry/analytics?adId=${adId}`);
if (res.ok) {
const data = await res.json();
setAnalytics(data.analytics);
}
} catch (error) {
console.error("Erro ao carregar analytics:", error);
}
};
useEffect(() => {
if (user?.userId) {
loadUserListings();
}
}, [user?.userId, loadUserListings]);
useEffect(() => {
if (selectedAd) {
loadAnalytics(selectedAd);
}
}, [selectedAd, loadAnalytics]);
if (!user) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
Faça login para ver suas analytics
</p>
</div>
);
}
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
</div>
);
}
if (listings.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
Você ainda não tem anúncios. Crie um anúncio para ver as analytics.
</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Analytics dos Anúncios</h1>
<p className="text-muted-foreground mt-1">
Acompanhe o desempenho dos seus anúncios
</p>
</div>
{/* Seletor de anúncio */}
<div className="space-y-2">
<label className="text-sm font-medium">Selecione um anúncio</label>
<select
className="w-full p-2 border rounded-md"
value={selectedAd || ""}
onChange={(e) => setSelectedAd(parseInt(e.target.value, 10))}
>
{listings.map((listing) => (
<option key={listing.listing_id} value={listing.listing_id}>
{listing.item_name} ({listing.listing_status})
</option>
))}
</select>
</div>
{/* Métricas */}
{analytics && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Impressões
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.impressions}</div>
<p className="text-sm text-muted-foreground mt-1">
Visualizações do anúncio
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MousePointerClick className="h-5 w-5" />
Cliques
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.clicks}</div>
<p className="text-sm text-muted-foreground mt-1">
CTR: {analytics.ctr.toFixed(2)}%
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Duração Média
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{analytics.avg_view_duration.toFixed(1)}s
</div>
<p className="text-sm text-muted-foreground mt-1">
Tempo médio de visualização
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Bounce Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{analytics.bounce_rate.toFixed(1)}%
</div>
<p className="text-sm text-muted-foreground mt-1">
Usuários que saíram em menos de 3s
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Inquiries
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.inquiries}</div>
<p className="text-sm text-muted-foreground mt-1">
Mensagens recebidas
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Heart className="h-5 w-5" />
Favoritos
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.favorites}</div>
<p className="text-sm text-muted-foreground mt-1">
Usuários que favoritaram
</p>
</CardContent>
</Card>
{analytics.conversion_rate > 0 && (
<Card>
<CardHeader>
<CardTitle>Taxa de Conversão</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{analytics.conversion_rate.toFixed(2)}%
</div>
<p className="text-sm text-muted-foreground mt-1">
Taxa de vendas
</p>
</CardContent>
</Card>
)}
{analytics.time_to_sell && (
<Card>
<CardHeader>
<CardTitle>Tempo até Venda</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{Math.round(analytics.time_to_sell)}h
</div>
<p className="text-sm text-muted-foreground mt-1">
Tempo médio até ser vendido
</p>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}
+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>
);
}
@@ -0,0 +1,524 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
Clock,
Loader2,
MessageSquare,
ShoppingBag,
ShoppingCart,
User,
XCircle,
} from "lucide-react";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { useSession } from "@/lib/hooks/use-session";
interface PurchaseIntention {
intention_id: number;
buyer_id: number;
payment_method: string;
status: "pending" | "accepted" | "rejected" | "expired" | "completed";
buyer_message: string | null;
seller_response: string | null;
created_at: string;
custom_offer_amount: number | null;
custom_offer_currency: string | null;
custom_offer_created_at: string | null;
buyer?: {
user_id: number;
username: string;
email: string;
};
}
interface IntentionDetailModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
intention: PurchaseIntention;
listingId: number;
onUpdate?: () => void;
}
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();
}
}
function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge
variant="secondary"
className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400"
>
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge
variant="secondary"
className="bg-green-500/20 text-green-700 dark:text-green-400"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge
variant="secondary"
className="bg-red-500/20 text-red-700 dark:text-red-400"
>
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
function formatCustomOffer(
intention: PurchaseIntention,
): { value: string; date: string | null } | null {
if (
intention.custom_offer_amount === null ||
intention.custom_offer_amount === undefined ||
!intention.custom_offer_currency
) {
return null;
}
const amount = intention.custom_offer_amount;
let value: string;
switch (intention.custom_offer_currency) {
case "pix":
value = `R$ ${amount.toFixed(2)}`;
break;
case "tibia_coins":
value = `${Math.round(amount).toLocaleString("pt-BR")} TC`;
break;
case "gold_coins":
value = `${Math.round(amount).toLocaleString("pt-BR")} GP`;
break;
default:
value = amount.toLocaleString("pt-BR");
}
return {
value,
date: intention.custom_offer_created_at
? new Date(intention.custom_offer_created_at).toLocaleString("pt-BR")
: null,
};
}
export function IntentionDetailModal({
open,
onOpenChange,
intention,
listingId,
onUpdate,
}: IntentionDetailModalProps) {
const { user } = useSession();
const queryClient = useQueryClient();
const [sellerResponse, setSellerResponse] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [listing, setListing] = useState<{
seller_id: number;
listing_status: string;
} | null>(null);
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false);
// Buscar detalhes do listing para verificar se usuário é dono e status
useEffect(() => {
if (open && listingId) {
fetch(`/api/listings/${listingId}`)
.then((res) => res.json())
.then((data) => {
if (data.success && data.listing) {
setListing(data.listing);
}
})
.catch((err) => console.error("Erro ao buscar listing:", err));
}
}, [open, listingId]);
const isOwner = user && listing && user.userId === listing.seller_id;
const canMarkAsSold =
isOwner &&
listing &&
(listing.listing_status === "active" ||
listing.listing_status === "in_escrow");
const canAcceptOrReject = intention.status === "pending";
const customOffer = formatCustomOffer(intention);
useEffect(() => {
if (open) {
setSellerResponse(intention.seller_response ?? "");
}
}, [open, intention.seller_response]);
const handleAccept = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/listings/${listingId}/intentions/${intention.intention_id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "accepted",
seller_response: sellerResponse || undefined,
}),
},
);
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Erro ao aceitar intenção");
}
if (onUpdate) onUpdate();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao aceitar intenção");
} finally {
setLoading(false);
}
};
const handleReject = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/listings/${listingId}/intentions/${intention.intention_id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "rejected",
seller_response: sellerResponse || undefined,
}),
},
);
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Erro ao rejeitar intenção");
}
if (onUpdate) onUpdate();
onOpenChange(false);
} catch (err) {
setError(
err instanceof Error ? err.message : "Erro ao rejeitar intenção",
);
} finally {
setLoading(false);
}
};
const handleMarkAsSold = async () => {
setMarkingSold(true);
try {
const res = await fetch(`/api/listings/${listingId}/mark-sold`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido");
}
// Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
if (onUpdate) onUpdate();
setShowMarkSoldDialog(false);
onOpenChange(false);
} catch (error) {
console.error("Erro ao marcar como vendido:", error);
alert(
error instanceof Error ? error.message : "Erro ao marcar como vendido",
);
} finally {
setMarkingSold(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Intenção de Compra
{getStatusBadge(intention.status)}
</DialogTitle>
<DialogDescription>
Detalhes da intenção de compra de{" "}
{intention.buyer?.username || "usuário"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Informações do comprador */}
<div className="bg-muted p-4 rounded-lg">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarFallback>
{intention.buyer?.username.charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<User className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold">
{intention.buyer?.username || "Usuário"}
</span>
</div>
<p className="text-sm text-muted-foreground">
{intention.buyer?.email || "Email não disponível"}
</p>
</div>
</div>
</div>
<Separator />
{/* Informações da intenção */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">
Método de Pagamento
</Label>
<div className="flex items-center gap-2 mt-1">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{getPaymentMethodLabel(intention.payment_method)}
</span>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Data de Criação
</Label>
<div className="flex items-center gap-2 mt-1">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{new Date(intention.created_at).toLocaleString("pt-BR")}
</span>
</div>
</div>
</div>
{customOffer && (
<div className="bg-primary/5 border border-primary/20 rounded-lg p-3 space-y-1">
<Label className="text-xs text-primary uppercase tracking-wide">
Oferta do Comprador
</Label>
<div className="text-base font-semibold text-primary">
{customOffer.value}
</div>
{customOffer.date && (
<p className="text-[11px] text-primary/70">
Enviada em {customOffer.date}
</p>
)}
</div>
)}
{/* Mensagem do comprador */}
{intention.buyer_message && (
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Mensagem do Comprador
</Label>
<div className="bg-muted p-3 rounded-lg">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5" />
<p className="text-sm flex-1">{intention.buyer_message}</p>
</div>
</div>
</div>
)}
{/* Resposta do vendedor */}
{intention.seller_response && (
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Sua Resposta
</Label>
<div className="bg-muted p-3 rounded-lg">
<p className="text-sm">{intention.seller_response}</p>
</div>
</div>
)}
</div>
{/* Campo de resposta (apenas se pendente) */}
{canAcceptOrReject && (
<div className="space-y-2">
<Label htmlFor="seller-response">Resposta (Opcional)</Label>
<Textarea
id="seller-response"
placeholder="Deixe uma mensagem para o comprador..."
value={sellerResponse}
onChange={(e) => setSellerResponse(e.target.value)}
rows={3}
/>
</div>
)}
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
{error}
</div>
)}
<Separator />
{/* Botões de ação */}
<div className="flex gap-2 justify-end flex-wrap">
{canMarkAsSold && (
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
disabled={loading || markingSold}
className="bg-green-600 hover:bg-green-700"
>
<ShoppingBag className="h-4 w-4 mr-2" />
Marcar como Vendido
</Button>
)}
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading || markingSold}
>
Fechar
</Button>
{canAcceptOrReject && (
<>
<Button
variant="destructive"
onClick={handleReject}
disabled={loading || markingSold}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Rejeitando...
</>
) : (
<>
<XCircle className="h-4 w-4 mr-2" />
Rejeitar
</>
)}
</Button>
<Button
onClick={handleAccept}
disabled={loading || markingSold}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Aceitando...
</>
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
Aceitar
</>
)}
</Button>
</>
)}
</div>
</div>
</DialogContent>
{/* Dialog de confirmação para marcar como vendido */}
<Dialog open={showMarkSoldDialog} onOpenChange={setShowMarkSoldDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão
executadas:
</DialogDescription>
</DialogHeader>
<div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
<li>O anúncio será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</li>
<li>
Após 1 hora, o anúncio sairá automaticamente da lista de
anúncios ativos
</li>
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul>
<p className="pt-2 font-medium text-foreground">
Tem certeza que deseja marcar este anúncio como vendido?
</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<Button
variant="outline"
onClick={() => setShowMarkSoldDialog(false)}
disabled={markingSold}
>
Cancelar
</Button>
<Button
onClick={handleMarkAsSold}
disabled={markingSold}
className="bg-green-600 hover:bg-green-700"
>
{markingSold ? "Marcando..." : "Sim, marcar como vendido"}
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
);
}
+239
View File
@@ -0,0 +1,239 @@
"use client";
import { Loader2, Search, X } from "lucide-react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface Item {
id: number;
name: string;
category?: string;
image?: string;
rarity?: string;
level?: number;
weight?: number;
npc_price?: number;
[key: string]: any;
}
interface ItemSearchProps {
onItemSelect: (item: Item) => void;
selectedItem?: Item | null;
placeholder?: string;
className?: string;
}
export function ItemSearch({
onItemSelect,
selectedItem,
placeholder = "Pesquisar item...",
className,
}: ItemSearchProps) {
const [searchQuery, setSearchQuery] = useState("");
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [previewItem, setPreviewItem] = useState<Item | null>(
selectedItem || null,
);
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
// Buscar itens ao digitar
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (searchQuery.trim().length < 2) {
setItems([]);
setShowDropdown(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`,
);
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
setShowDropdown(true);
}
} catch (error) {
console.error("Erro ao buscar itens:", error);
} finally {
setLoading(false);
}
}, 300);
}, [searchQuery]);
// Fechar dropdown ao clicar fora
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
searchRef.current &&
!searchRef.current.contains(event.target as Node)
) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Atualizar preview quando item selecionado mudar externamente
useEffect(() => {
if (selectedItem) {
setPreviewItem(selectedItem);
setSearchQuery(selectedItem.name);
}
}, [selectedItem]);
const handleItemClick = (item: Item) => {
setPreviewItem(item);
setSearchQuery(item.name);
setShowDropdown(false);
onItemSelect(item);
};
const handleClear = () => {
setSearchQuery("");
setPreviewItem(null);
setItems([]);
setShowDropdown(false);
onItemSelect(null as any);
};
return (
<div ref={searchRef} className={cn("relative", className)}>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => searchQuery.length >= 2 && setShowDropdown(true)}
placeholder={placeholder}
className="pl-9 pr-9"
/>
{searchQuery && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
)}
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
{/* Dropdown de resultados */}
{showDropdown && items.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-y-auto">
{items.map((item) => (
<div
key={item.id}
className="p-2 hover:bg-accent cursor-pointer flex items-center gap-3"
onClick={() => handleItemClick(item)}
>
{item.image && (
<div className="relative w-10 h-10 flex-shrink-0 rounded bg-muted">
<Image
src={item.image}
alt={item.name}
fill
style={{ objectFit: "contain" }}
sizes="40px"
/>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.name}</p>
{item.category && (
<p className="text-xs text-muted-foreground">
{item.category}
</p>
)}
</div>
{item.rarity && (
<Badge variant="secondary" className="text-xs">
{item.rarity}
</Badge>
)}
</div>
))}
</div>
)}
{/* Preview do item selecionado */}
{previewItem && (
<Card className="mt-3">
<CardContent className="p-4">
<div className="flex gap-4">
{previewItem.image && (
<div className="relative w-20 h-20 flex-shrink-0 rounded-lg bg-muted overflow-hidden">
<Image
src={previewItem.image}
alt={previewItem.name}
fill
style={{ objectFit: "contain" }}
sizes="80px"
/>
</div>
)}
<div className="flex-1">
<h4 className="font-semibold text-lg">{previewItem.name}</h4>
<div className="flex flex-wrap gap-2 mt-2 text-sm text-muted-foreground">
{previewItem.category && (
<span className="flex items-center gap-1">
<span className="font-medium">Categoria:</span>
{previewItem.category}
</span>
)}
{previewItem.rarity && (
<Badge variant="secondary">{previewItem.rarity}</Badge>
)}
{previewItem.level !== undefined &&
previewItem.level !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Level:</span>
{previewItem.level}
</span>
)}
{previewItem.weight !== undefined &&
previewItem.weight !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Peso:</span>
{previewItem.weight} oz
</span>
)}
{previewItem.npc_price !== undefined &&
previewItem.npc_price !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Preço NPC:</span>
{previewItem.npc_price.toLocaleString()} gp
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,610 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
Eye,
Heart,
MapPin,
Pause,
ShoppingBag,
Star,
Trash2,
TrendingUp,
X,
} from "lucide-react";
import Image from "next/image";
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 { useSession } from "@/lib/hooks/use-session";
interface Listing {
listing_id: number;
seller_id: number;
item_id: number;
item_name: string;
quantity: number;
server_name: string;
price_pix: number | string | null;
price_tibia_coins: number | string | null;
price_gold_coins: number | string | null;
is_auction: boolean;
description: string | null;
listing_status: string;
created_at: string;
views_count: number;
favorites_count: number;
is_featured?: boolean;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
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;
auction_ends_at?: Date | null;
item_id_unified?: number | null;
image_local_path?: string | null;
image_url?: string | null;
item_category?: string | null;
seller_character_name?: string | null;
highest_custom_offer_amount?: number | string | null;
highest_custom_offer_currency?: string | null;
}
interface ListingDetailsModalProps {
listing: Listing;
open: boolean;
onOpenChange: (open: boolean) => void;
onPause?: () => void;
onActivate?: () => void;
onCancel?: () => void;
}
function formatTimeAgo(date: Date | string): string {
const dateObj = typeof date === "string" ? new Date(date) : date;
const seconds = Math.floor((Date.now() - dateObj.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24);
if (days < 30) return `há ${days} ${days === 1 ? "dia" : "dias"}`;
const months = Math.floor(days / 30);
if (months < 12) return `há ${months} ${months === 1 ? "mês" : "meses"}`;
const years = Math.floor(months / 12);
return `há ${years} ${years === 1 ? "ano" : "anos"}`;
}
function getStatusBadge(status: string) {
const statusConfig = {
active: {
label: "Ativo",
variant: "default" as const,
icon: CheckCircle2,
className: "bg-green-500 hover:bg-green-600",
},
sold: {
label: "Vendido",
variant: "secondary" as const,
icon: CheckCircle2,
className: "bg-blue-500 hover:bg-blue-600 text-white",
},
cancelled: {
label: "Cancelado",
variant: "destructive" as const,
icon: X,
className: "bg-red-500 hover:bg-red-600 text-white",
},
in_escrow: {
label: "Em Escrow",
variant: "default" as const,
icon: AlertCircle,
className: "bg-yellow-500 hover:bg-yellow-600 text-white",
},
expired: {
label: "Expirado",
variant: "secondary" as const,
icon: Clock,
className: "bg-gray-500 hover:bg-gray-600 text-white",
},
};
const config =
statusConfig[status as keyof typeof statusConfig] || statusConfig.active;
const Icon = config.icon;
return (
<Badge className={config.className}>
<Icon className="h-3 w-3 mr-1" />
{config.label}
</Badge>
);
}
export function ListingDetailsModal({
listing,
open,
onOpenChange,
onPause,
onActivate,
onCancel,
}: ListingDetailsModalProps) {
const { user } = useSession();
const queryClient = useQueryClient();
const [itemImageUrl, setItemImageUrl] = useState<string>("");
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false);
// Verificar se o usuário é o dono do anúncio
const isOwner = user && user.userId === listing.seller_id;
useEffect(() => {
// Construir URL da imagem
if (listing.image_url) {
setItemImageUrl(listing.image_url);
} else if (listing.image_local_path) {
const normalizedPath = listing.image_local_path.replace(/\\/g, "/");
if (normalizedPath.startsWith("data/")) {
setItemImageUrl(
`/api/images?path=${encodeURIComponent(normalizedPath)}`,
);
} else {
const category = listing.item_category || "other";
setItemImageUrl(
`/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`,
);
}
} else if (listing.item_id_unified) {
const params = new URLSearchParams({
item: listing.item_id_unified.toString(),
});
if (listing.item_name) {
params.set("name", listing.item_name);
}
setItemImageUrl(`/api/images?${params.toString()}`);
} else {
const params = new URLSearchParams({ item: listing.item_id.toString() });
if (listing.item_name) {
params.set("name", listing.item_name);
}
setItemImageUrl(`/api/images?${params.toString()}`);
}
}, [listing]);
const getAvailablePrices = () => {
const prices: Array<{ type: string; label: string; value: string }> = [];
if (listing.price_pix) {
const price =
typeof listing.price_pix === "string"
? parseFloat(listing.price_pix)
: listing.price_pix;
if (!Number.isNaN(price)) {
prices.push({
type: "pix",
label: "PIX",
value: `R$ ${price.toFixed(2)}`,
});
}
}
if (listing.price_tibia_coins) {
const price =
typeof listing.price_tibia_coins === "string"
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
if (!Number.isNaN(price)) {
prices.push({
type: "tibia_coins",
label: "Tibia Coins",
value: `${price.toLocaleString()} TC`,
});
}
}
if (listing.price_gold_coins) {
const price =
typeof listing.price_gold_coins === "string"
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
if (!Number.isNaN(price)) {
prices.push({
type: "gold_coins",
label: "Gold Coins",
value: `${price.toLocaleString()} GP`,
});
}
}
if (
listing.trade_item_id &&
listing.trade_item_name &&
listing.trade_item_quantity
) {
prices.push({
type: "trade",
label: "Troca",
value: `${listing.trade_item_quantity}x ${listing.trade_item_name}`,
});
}
return prices;
};
const prices = getAvailablePrices();
const highestOfferLabel = (() => {
if (
listing.highest_custom_offer_amount === null ||
listing.highest_custom_offer_amount === undefined ||
!listing.highest_custom_offer_currency
) {
return null;
}
const amount =
typeof listing.highest_custom_offer_amount === "string"
? Number(listing.highest_custom_offer_amount)
: listing.highest_custom_offer_amount;
if (Number.isNaN(amount)) {
return null;
}
switch (listing.highest_custom_offer_currency) {
case "pix":
return `Maior oferta registrada: R$ ${amount.toFixed(2)}`;
case "tibia_coins":
return `Maior oferta registrada: ${Math.round(amount).toLocaleString()} TC`;
case "gold_coins":
return `Maior oferta registrada: ${Math.round(amount).toLocaleString()} GP`;
default:
return `Maior oferta registrada: ${amount}`;
}
})();
// Apenas o dono pode realizar ações
const canPause = isOwner && listing.listing_status === "active";
const canActivate = isOwner && listing.listing_status === "cancelled";
const canCancel = isOwner && listing.listing_status === "active";
const canMarkAsSold =
isOwner &&
(listing.listing_status === "active" ||
listing.listing_status === "in_escrow");
const handleMarkAsSold = async () => {
setMarkingSold(true);
try {
const res = await fetch(`/api/listings/${listing.listing_id}/mark-sold`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido");
}
// Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
setShowMarkSoldDialog(false);
onOpenChange(false);
} catch (error) {
console.error("Erro ao marcar como vendido:", error);
alert(
error instanceof Error ? error.message : "Erro ao marcar como vendido",
);
} finally {
setMarkingSold(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Detalhes do Anúncio
{getStatusBadge(listing.listing_status)}
</DialogTitle>
<DialogDescription>
Informações completas do seu anúncio
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Imagem e informações principais */}
<div className="flex gap-6">
{/* Imagem do item */}
<div className="relative w-48 h-48 flex-shrink-0 rounded-lg overflow-hidden bg-muted flex items-center justify-center p-4 border">
<Image
src={itemImageUrl}
alt={listing.item_name}
fill
className="object-contain"
sizes="192px"
onError={(e) => {
(e.target as HTMLImageElement).src = "/placeholder-item.png";
}}
/>
</div>
{/* Informações do item */}
<div className="flex-1 space-y-4">
<div>
<h3 className="text-2xl font-bold mb-2">{listing.item_name}</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="h-4 w-4" />
<span>Servidor: {listing.server_name}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span>Quantidade: {listing.quantity}</span>
</div>
</div>
{/* Estatísticas */}
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">
Visualizações
</div>
<div className="font-semibold">{listing.views_count}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">
Favoritos
</div>
<div className="font-semibold">
{listing.favorites_count || 0}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Criado</div>
<div className="font-semibold text-xs">
{formatTimeAgo(listing.created_at)}
</div>
</div>
</div>
</div>
{/* Badges */}
<div className="flex flex-wrap gap-2">
{listing.is_featured && (
<Badge variant="default" className="bg-yellow-500">
<Star className="h-3 w-3 mr-1" />
Destaque
</Badge>
)}
{listing.is_auction && (
<Badge variant="secondary">
<TrendingUp className="h-3 w-3 mr-1" />
Leilão
</Badge>
)}
</div>
</div>
</div>
{/* Preços */}
<div className="space-y-2">
<h4 className="font-semibold">Preços</h4>
<div className="grid grid-cols-2 gap-3">
{prices.map((price, index) => (
<div
key={index}
className={`p-3 rounded-lg border ${
price.type === "pix"
? "bg-green-500/10 border-green-500/20"
: price.type === "tibia_coins"
? "bg-blue-500/10 border-blue-500/20"
: price.type === "gold_coins"
? "bg-yellow-500/10 border-yellow-500/20"
: "bg-purple-500/10 border-purple-500/20"
}`}
>
<div className="text-xs text-muted-foreground mb-1">
{price.label}
</div>
<div className="font-semibold">{price.value}</div>
</div>
))}
</div>
{highestOfferLabel && (
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm font-medium text-primary">
{highestOfferLabel}
</div>
)}
</div>
{/* Informações de leilão */}
{listing.is_auction && (
<div className="space-y-2 p-4 bg-muted rounded-lg">
<h4 className="font-semibold">Informações do Leilão</h4>
{listing.current_bid_amount ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Lance Atual:</span>
<span className="font-semibold text-lg text-primary">
R${" "}
{typeof listing.current_bid_amount === "number"
? listing.current_bid_amount.toFixed(2)
: parseFloat(
String(listing.current_bid_amount),
).toFixed(2)}
</span>
</div>
{listing.min_bid_increment && (
<div className="flex justify-between">
<span className="text-muted-foreground">
Incremento Mínimo:
</span>
<span className="font-medium">
R${" "}
{typeof listing.min_bid_increment === "number"
? listing.min_bid_increment.toFixed(2)
: parseFloat(
String(listing.min_bid_increment),
).toFixed(2)}
</span>
</div>
)}
{listing.auction_ends_at && (
<div className="flex justify-between">
<span className="text-muted-foreground">Termina em:</span>
<span className="font-medium">
{new Date(listing.auction_ends_at).toLocaleString(
"pt-BR",
)}
</span>
</div>
)}
</div>
) : (
<div className="text-center text-muted-foreground py-2">
Sem lances ainda
</div>
)}
</div>
)}
{/* Descrição */}
{listing.description && (
<div className="space-y-2">
<h4 className="font-semibold">Descrição</h4>
<p className="text-sm text-muted-foreground p-3 bg-muted rounded-lg">
{listing.description}
</p>
</div>
)}
{/* Informações adicionais */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">ID do Anúncio:</span>
<span className="ml-2 font-mono">{listing.listing_id}</span>
</div>
{listing.item_id_unified && (
<div>
<span className="text-muted-foreground">
ID do Item (Unificado):
</span>
<span className="ml-2 font-mono">
{listing.item_id_unified}
</span>
</div>
)}
<div>
<span className="text-muted-foreground">Criado em:</span>
<span className="ml-2">
{new Date(listing.created_at).toLocaleString("pt-BR")}
</span>
</div>
</div>
{/* Ações */}
{(canPause || canActivate || canCancel || canMarkAsSold) && (
<div className="flex gap-2 pt-4 border-t">
{canMarkAsSold && (
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
className="flex-1 bg-green-600 hover:bg-green-700"
>
<ShoppingBag className="h-4 w-4 mr-2" />
Marcar como Vendido
</Button>
)}
{canPause && (
<Button variant="outline" onClick={onPause} className="flex-1">
<Pause className="h-4 w-4 mr-2" />
Pausar
</Button>
)}
{canActivate && onActivate && (
<Button
variant="default"
onClick={onActivate}
className="flex-1"
>
<CheckCircle2 className="h-4 w-4 mr-2" />
Reativar Anúncio
</Button>
)}
{canCancel && (
<Button
variant="destructive"
onClick={onCancel}
className="flex-1"
>
<Trash2 className="h-4 w-4 mr-2" />
Cancelar Anúncio
</Button>
)}
</div>
)}
</div>
</DialogContent>
{/* Dialog de confirmação para marcar como vendido */}
<Dialog open={showMarkSoldDialog} onOpenChange={setShowMarkSoldDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão
executadas:
</DialogDescription>
</DialogHeader>
<div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
<li>O anúncio será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</li>
<li>
Após 1 hora, o anúncio sairá automaticamente da lista de
anúncios ativos
</li>
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul>
<p className="pt-2 font-medium text-foreground">
Tem certeza que deseja marcar este anúncio como vendido?
</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<Button
variant="outline"
onClick={() => setShowMarkSoldDialog(false)}
disabled={markingSold}
>
Cancelar
</Button>
<Button
onClick={handleMarkAsSold}
disabled={markingSold}
className="bg-green-600 hover:bg-green-700"
>
{markingSold ? "Marcando..." : "Sim, marcar como vendido"}
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
);
}
@@ -0,0 +1,294 @@
"use client";
import {
CheckCircle2,
Clock,
MessageSquare,
ShoppingCart,
XCircle,
} from "lucide-react";
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 { Skeleton } from "@/components/ui/skeleton";
import { IntentionDetailModal } from "./IntentionDetailModal";
interface PurchaseIntention {
intention_id: number;
buyer_id: number;
payment_method: string;
status: "pending" | "accepted" | "rejected" | "expired" | "completed";
buyer_message: string | null;
seller_response: string | null;
created_at: string;
custom_offer_amount: number | null;
custom_offer_currency: string | null;
custom_offer_created_at: string | null;
buyer?: {
user_id: number;
username: string;
email: string;
};
}
interface MyAdsIntentionListProps {
listingId: number;
sellerId: number;
}
function formatTimeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24);
return `há ${days} ${days === 1 ? "dia" : "dias"}`;
}
function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge
variant="secondary"
className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400"
>
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge
variant="secondary"
className="bg-green-500/20 text-green-700 dark:text-green-400"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge
variant="secondary"
className="bg-red-500/20 text-red-700 dark:text-red-400"
>
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
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();
}
}
function formatCustomOffer(intention: PurchaseIntention): string | null {
if (
intention.custom_offer_amount === null ||
intention.custom_offer_amount === undefined ||
!intention.custom_offer_currency
) {
return null;
}
const amount = intention.custom_offer_amount;
switch (intention.custom_offer_currency) {
case "pix":
return `R$ ${amount.toFixed(2)}`;
case "tibia_coins":
return `${Math.round(amount).toLocaleString("pt-BR")} TC`;
case "gold_coins":
return `${Math.round(amount).toLocaleString("pt-BR")} GP`;
default:
return amount.toLocaleString("pt-BR");
}
}
export function MyAdsIntentionList({
listingId,
sellerId,
}: MyAdsIntentionListProps) {
const [intentions, setIntentions] = useState<PurchaseIntention[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIntention, setSelectedIntention] =
useState<PurchaseIntention | null>(null);
const [filter, setFilter] = useState<
"all" | "pending" | "accepted" | "rejected"
>("all");
const loadIntentions = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filter !== "all") {
params.set("status", filter);
}
const res = await fetch(
`/api/listings/${listingId}/intentions?${params.toString()}`,
);
if (res.ok) {
const data = await res.json();
if (data.success) {
setIntentions(data.intentions || []);
}
}
} catch (error) {
console.error("Erro ao carregar intenções:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadIntentions();
}, [loadIntentions]);
const pendingCount = intentions.filter((i) => i.status === "pending").length;
if (loading) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
);
}
if (intentions.length === 0) {
return (
<div className="text-center py-8 text-sm text-muted-foreground">
Nenhuma intenção de compra encontrada
</div>
);
}
return (
<div className="space-y-4">
{/* Filtros */}
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={filter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("all")}
>
Todas ({intentions.length})
</Button>
<Button
variant={filter === "pending" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("pending")}
>
Pendentes ({pendingCount})
</Button>
<Button
variant={filter === "accepted" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("accepted")}
>
Aceitas
</Button>
<Button
variant={filter === "rejected" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("rejected")}
>
Rejeitadas
</Button>
</div>
{/* Lista de intenções */}
<div className="space-y-2">
{intentions.map((intention) => (
<div
key={intention.intention_id}
className="border rounded-lg p-4 hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => setSelectedIntention(intention)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<Avatar className="h-10 w-10">
<AvatarFallback>
{intention.buyer?.username.charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">
{intention.buyer?.username || "Usuário"}
</span>
{getStatusBadge(intention.status)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<ShoppingCart className="h-3 w-3" />
<span>
{getPaymentMethodLabel(intention.payment_method)}
</span>
<span>•</span>
<Clock className="h-3 w-3" />
<span>{formatTimeAgo(new Date(intention.created_at))}</span>
</div>
{formatCustomOffer(intention) && (
<div className="flex items-center gap-2 mb-1">
<Badge
variant="outline"
className="border-primary text-primary"
>
Oferta: {formatCustomOffer(intention)}
</Badge>
{intention.custom_offer_created_at && (
<span className="text-[10px] text-muted-foreground">
{new Date(
intention.custom_offer_created_at,
).toLocaleString("pt-BR")}
</span>
)}
</div>
)}
{intention.buyer_message && (
<p className="text-xs text-muted-foreground line-clamp-1">
<MessageSquare className="h-3 w-3 inline mr-1" />
{intention.buyer_message}
</p>
)}
</div>
</div>
</div>
</div>
))}
</div>
{/* Modal de detalhes */}
{selectedIntention && (
<IntentionDetailModal
open={!!selectedIntention}
onOpenChange={(open) => {
if (!open) setSelectedIntention(null);
}}
intention={selectedIntention}
listingId={listingId}
onUpdate={loadIntentions}
/>
)}
</div>
);
}
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff. Show more