1 Commits
Author SHA1 Message Date
Evandro Fonseca Junior 3fb5ce8f0f Merge pull request #5 from evandrodevbr:develop
Develop
2025-10-30 19:06:07 -03:00
276 changed files with 1228 additions and 39613 deletions

No files matched your search

+43 -90
View File
@@ -1,105 +1,58 @@
<div align="center"> # Tibia Trade - Marketplace Seguro para Itens de Tibia
<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"> 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.
![Next.js 15.5.0](https://img.shields.io/badge/Next.js-15.5.0-ffd700?style=flat-square&labelColor=0a0a0a) ## 🎯 Características
![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)
[Plano de melhorias](docs/PLANO_MELHORIAS_MARKETPLACE.md) · [Configurar o AbacatePay](ABACATEPAY_SETUP.md) · [Issues](https://git.evandro.dev.br/evandro/pagodera/issues) - **Marketplace Multi-Servidor**: Funciona com qualquer servidor de Tibia
- **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
</div> ## 🚀 Como Iniciar
O catálogo de itens vem no repositório (`data/tibiawiki.db`, SQLite com dados do TibiaWiki BR). Lido com o SQLite embutido do Node 22.13+, sem instalar nada:
```console
$ node --no-warnings -e "const db = new (require('node:sqlite').DatabaseSync)('data/tibiawiki.db', { readOnly: true }); const t = db.prepare(\"select name from sqlite_master where type = 'table' and name not like 'sqlite_%'\").all(); const n = t.reduce((s, { name }) => s + db.prepare('select count(*) as c from ' + name).get().c, 0); console.log(t.length + ' categorias, ' + n + ' itens'); console.table(db.prepare('select nome, lvl, voc, peso from espadas order by lvl desc limit 3').all())"
28 categorias, 2575 itens
┌─────────┬────────────────────────┬─────┬───────────┬──────┐
│ (index) │ nome │ lvl │ voc │ peso │
├─────────┼────────────────────────┼─────┼───────────┼──────┤
│ 0 │ 'Grand Sanguine Blade' │ 600 │ 'Knights' │ 6000 │
│ 1 │ 'Grand Sanguine Razor' │ 600 │ 'Knights' │ 7900 │
│ 2 │ 'Sanguine Blade' │ 600 │ 'Knights' │ 6100 │
└─────────┴────────────────────────┴─────┴───────────┴──────┘
```
## O que é
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`).
| 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" |
## Por que usar
- **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.
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.
## Como funciona
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 ```bash
git clone https://git.evandro.dev.br/evandro/pagodera.git # Instalar dependências
cd pagodera pnpm install
pnpm install # o postinstall roda `prisma generate`
pnpm db:migrate # aplica as migrações SQL no PostgreSQL (PG_DB) # Desenvolvimento
pnpm db:seed # cadastra os servidores de Tibia pnpm dev
pnpm dev # tsx server.ts → http://localhost:3000
# Build para produção
pnpm build
# Iniciar servidor de produção
pnpm start
``` ```
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). ## 🛠️ Tecnologias
## Configuração - **Framework**: Next.js 15 (App Router)
- **Styling**: Tailwind CSS 4
- **UI Components**: Radix UI
- **Animações**: Motion (Framer Motion)
- **Internacionalização**: next-intl
- **Validação**: Zod
- **Typescript**: TypeScript 5
| Variável | Uso | ## 📦 Palavras-chave SEO
|---|---|
| `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 - tibia marketplace
- tibia trade
- comprar itens tibia
- vender itens tibia
- tibia escrow
- tibia gold
- tibia coins
- tc tibia
- marketplace tibia seguro
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. ## 🌐 Domínios
O que falta: - Desenvolvimento: https://tibiatrade.evandro.dev.br
- Produção: https://tibiatrading.com (futuro)
- Escrow e pagamento entre usuários (veja "Quando não usar"). ## 📄 Licença
- 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 Privado - Uso interno
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
@@ -1,59 +0,0 @@
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
@@ -1,108 +0,0 @@
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
@@ -1,18 +0,0 @@
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`);
}
+5 -5
View File
@@ -1,13 +1,13 @@
"use client"; "use client";
import confetti from "canvas-confetti";
import { Heart, Home, Share2 } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useParams } from "next/navigation";
import confetti from "canvas-confetti";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { BlurFade } from "@/components/ui/blur-fade";
import { Heart, Share2, Home } from "lucide-react";
import Link from "next/link";
export default function ThankYouPage() { export default function ThankYouPage() {
const params = useParams(); const params = useParams();
+11 -10
View File
@@ -1,19 +1,20 @@
import { Heart } from "lucide-react";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { DonationCard } from "@/components/donation-card";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { ShareButton } from "@/components/share-button"; import { DonationCard } from "@/components/donation-card";
import { BlurFade } from "@/components/ui/blur-fade";
import { NumberTicker } from "@/components/ui/number-ticker";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge"; import { Heart } from "lucide-react";
import { BlurFade } from "@/components/ui/blur-fade"; import { getTranslations } from "next-intl/server";
import { Button } from "@/components/ui/button"; import Link from "next/link";
import { NumberTicker } from "@/components/ui/number-ticker"; import { ShareButton } from "@/components/share-button";
import type { Metadata } from "next";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -59,7 +60,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
+40 -30
View File
@@ -1,9 +1,20 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Script from "next/script"; import { Geist, Geist_Mono, Nunito_Sans } from "next/font/google";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server"; import { getMessages } from "next-intl/server";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { QueryProvider } from "@/lib/providers/query-provider"; 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 async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -86,13 +97,6 @@ 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",
},
}; };
} }
@@ -104,21 +108,20 @@ 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 (
<QueryProvider> <html lang={locale} suppressHydrationWarning>
<NextIntlClientProvider messages={messages}> <head>
<ThemeProvider attribute="class" defaultTheme="dark"> <link rel="icon" href="/favicon.ico" sizes="any" />
<Script <link rel="icon" href="/icon.svg" type="image/svg+xml" />
id="structured-data-organization" <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<script
type="application/ld+json" type="application/ld+json"
strategy="afterInteractive" dangerouslySetInnerHTML={{
> __html: JSON.stringify({
{JSON.stringify({
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Organization", "@type": "Organization",
name: "Tibia Trade", name: "Tibia Trade",
@@ -132,14 +135,13 @@ export default async function LocaleLayout({
contactType: "Customer Support", contactType: "Customer Support",
availableLanguage: ["Portuguese", "English"], availableLanguage: ["Portuguese", "English"],
}, },
})} }),
</Script> }}
<Script />
id="structured-data-website" <script
type="application/ld+json" type="application/ld+json"
strategy="afterInteractive" dangerouslySetInnerHTML={{
> __html: JSON.stringify({
{JSON.stringify({
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
name: "Tibia Trade", name: "Tibia Trade",
@@ -152,11 +154,19 @@ 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>
</QueryProvider> </body>
</html>
); );
} }
+21 -96
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 { ItemCard } from "@/components/item-card";
import { MarketplaceFilters } from "@/components/marketplace-filters"; import { MarketplaceFilters } from "@/components/marketplace-filters";
import { ItemCard } from "@/components/item-card";
interface Item { interface Item {
id: string | number; id: string | number;
@@ -38,9 +38,7 @@ 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< const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[] | undefined>(undefined);
{ 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);
@@ -79,10 +77,7 @@ 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) => ({ const opts = (data.categories as string[]).map((c) => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) }));
value: c,
label: c.charAt(0).toUpperCase() + c.slice(1),
}));
setCategoryOptions(opts); setCategoryOptions(opts);
} }
} catch { } catch {
@@ -95,12 +90,7 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
setPage(1); setPage(1);
setQuery(q); setQuery(q);
}; };
const onFilter = (f: { const onFilter = (f: { rarity: string[]; category: string[]; priceRange?: { min: number; max: number }; vocation?: string | null }) => {
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 || []);
@@ -113,17 +103,10 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<MarketplaceFilters <MarketplaceFilters onSearch={onSearch} onFilter={onFilter} onSort={onSort} totalItems={total} showRarity={false} categoryOptionsOverride={categoryOptions} />
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, idx) => { {items.map((item) => {
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 => {
@@ -159,31 +142,9 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return map[v] || v || "other"; return map[v] || v || "other";
}; };
const categoryPt = const categoryPt = typeof anyItem.categoria === "string" ? anyItem.categoria : undefined;
typeof anyItem.categoria === "string" const category = normalizeCategory(item.category ?? categoryPt);
? anyItem.categoria const name = item.name ?? anyItem.nome ?? String(item.id);
: 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
@@ -231,8 +192,7 @@ 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" && : typeof anyItem.skill_para_usar === "string" && anyItem.skill_para_usar !== "Nenhum."
anyItem.skill_para_usar !== "Nenhum."
? anyItem.skill_para_usar ? anyItem.skill_para_usar
: undefined; : undefined;
const vocation: string | undefined = const vocation: string | undefined =
@@ -240,31 +200,11 @@ 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={safeKey} key={`${String((anyItem as any).category)}::${String(item.id)}::${name}`}
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,
@@ -283,24 +223,11 @@ 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: volume: typeof anyItem.volume === "number" ? anyItem.volume : undefined,
typeof anyItem.volume === "number" bonus_de_skill: typeof anyItem.bonus_de_skill === "number" ? anyItem.bonus_de_skill : undefined,
? anyItem.volume protecao: typeof anyItem.protecao === "string" ? anyItem.protecao : undefined,
: undefined, dropa_de: typeof anyItem.dropa_de === "string" ? anyItem.dropa_de : undefined,
bonus_de_skill: } as any}
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"
/> />
); );
@@ -309,18 +236,14 @@ 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"> <span className="text-sm text-muted-foreground">Página {page} de {totalPages}</span>
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}
@@ -331,3 +254,5 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
</div> </div>
); );
} }
+7 -15
View File
@@ -1,5 +1,5 @@
import { getTranslations } from "next-intl/server";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { getTranslations } from "next-intl/server";
import LibraryClient from "./LibraryClient"; import LibraryClient from "./LibraryClient";
interface Item { interface Item {
@@ -18,24 +18,17 @@ interface Item {
async function fetchItems(params: URLSearchParams) { async function fetchItems(params: URLSearchParams) {
const qs = params.toString(); const qs = params.toString();
const res = await fetch( const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`, {
`${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({ export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
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 {
@@ -44,10 +37,7 @@ export async function generateMetadata({
}; };
} }
export default async function LibraryPage({ export default async function LibraryPage({ params, searchParams }: {
params,
searchParams,
}: {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>; searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
@@ -79,3 +69,5 @@ export default async function LibraryPage({
</div> </div>
); );
} }
-456
View File
@@ -1,456 +0,0 @@
"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
@@ -1,37 +0,0 @@
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>
);
}
+11 -62
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { getActiveListings } from "@/lib/prisma/queries/listings"; import { UnderConstruction } from "@/components/under-construction";
import MarketClient from "./MarketClient"; import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -9,6 +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 titles = { const titles = {
pt: "Mercado - Todos os Itens de Tibia", pt: "Mercado - Todos os Itens de Tibia",
@@ -36,75 +37,23 @@ 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 sp = (await searchParams) ?? {}; const t = await getTranslations({
locale,
const page = typeof sp.page === "string" ? parseInt(sp.page, 10) : 1; namespace: "underConstruction.market",
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 />
<main className="container mx-auto px-4 py-8"> <UnderConstruction
<MarketClient title={t("title")}
initial={{ description={t("description")}
listings: serializedListings as any, locale={locale}
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 { MarketExample } from "@/components/sections/market-example"; import { FeaturedItems } from "@/components/sections/featured-items";
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 aria-label="Página principal do Tibia Trade"> <main role="main" aria-label="Página principal do Tibia Trade">
<HeroSection /> <HeroSection />
<HowItWorks /> <HowItWorks />
<FeaturedItems /> <FeaturedItems />
<MarketExample /> <MarketOffers />
<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,
@@ -1,68 +0,0 @@
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
@@ -1,99 +0,0 @@
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
@@ -1,110 +0,0 @@
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
@@ -1,356 +0,0 @@
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
@@ -1,223 +0,0 @@
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
@@ -1,91 +0,0 @@
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
@@ -1,80 +0,0 @@
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
@@ -1,272 +0,0 @@
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
@@ -1,207 +0,0 @@
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
@@ -1,271 +0,0 @@
/**
* 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
@@ -1,78 +0,0 @@
/**
* 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
@@ -1,72 +0,0 @@
/**
* 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
@@ -1,266 +0,0 @@
/**
* 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
@@ -1,70 +0,0 @@
/**
* 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 { type NextRequest, NextResponse } from "next/server"; import { 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,
createRateLimitResponse,
getClientIP, getClientIP,
createRateLimitResponse,
} 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 { type NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getPaymentStatus } from "@/lib/payment-cache"; import { getPaymentStatus } from "@/lib/payment-cache";
import { import {
checkRateLimit, checkRateLimit,
createRateLimitResponse,
getClientIP, getClientIP,
createRateLimitResponse,
} 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 { type NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay"; import { abacatePay } from "@/lib/abacatepay";
import { import {
checkRateLimit, checkRateLimit,
createRateLimitResponse,
getClientIP, getClientIP,
createRateLimitResponse,
} 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 }
); );
} }
} }
+6 -107
View File
@@ -1,112 +1,14 @@
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(), imagePath); const resolved = path.join(process.cwd(), relPath);
if (!resolved.startsWith(baseDir)) { if (!resolved.startsWith(baseDir)) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
@@ -124,13 +26,10 @@ 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, { return new Response(file, { status: 200, headers: { "Content-Type": contentType } });
status: 200, } catch {
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 });
} }
} }
+18 -13
View File
@@ -1,20 +1,25 @@
import type { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma/app"; import path from "node:path";
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 categories = await prisma.item.findMany({ const db = getDb();
distinct: ["category"], const tables = db
select: { category: true }, .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence' ORDER BY name")
orderBy: { category: "asc" }, .all() as { name: string }[];
}); db.close();
// Retorna nomes das tabelas (PT) para usar como categorias
return Response.json({ return Response.json({ categories: tables.map((t) => t.name) });
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 });
} }
} }
+31 -63
View File
@@ -1,95 +1,61 @@
import type { NextRequest } from "next/server"; import { 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((value) => value.trim()) .map((v) => v.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 server = searchParams.get("server") || undefined; const sortParam = (searchParams.get("sort") || "name") as
const sortParamRaw = (searchParams.get("sort") || "name").toLowerCase(); | "name"
const sortParam = (allowedSorts.find((sort) => sort === sortParamRaw) || | "level-asc"
"name") as ItemSort; | "level-desc"
| "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 } = await searchItems({ const { items, total } = searchItems({
q, q,
category: categories[0], categories,
rarity: rarities[0], rarities,
server,
sort: sortParam, sort: sortParam,
vocation: voc,
page, page,
pageSize, pageSize,
}); });
// Normaliza campos esperados pelo cliente, preservando compatibilidade // Enriquecer com URL pública de imagem quando possível
const itemsWithImage = items.map((item) => { const itemsWithImage = items.map((it: any) => {
const imgRaw = (item.image_url || item.image_local_path || "").toString(); const img = (it.image || "").toString();
const categorySafe = item.category || "other"; // Preferir URL absoluta do banco para teste
if (/^https?:\/\//i.test(img)) {
const idSafe = typeof item.item_id === "number" ? item.item_id : undefined; return { ...it, image: img };
const nameSafe =
typeof (item as any).item_name === "string" && (item as any).item_name
? (item as any).item_name
: undefined;
const base = {
...item,
// Aliases usados pelos clientes públicos (Library, etc.)
id: idSafe,
name: nameSafe,
category: categorySafe,
} as any;
if (/^https?:\/\//i.test(imgRaw)) {
return { ...base, image_url: imgRaw, image: imgRaw };
} }
if (imgRaw.startsWith("data/")) { if (img.startsWith("data/")) {
return { return { ...it, image: `/api/images?path=${encodeURIComponent(img)}` };
...base,
image_url: imgRaw,
image: `/api/images?path=${encodeURIComponent(imgRaw)}`,
};
} }
return { const categoryEn = (it.category || "other").toString();
...base, return { ...it, image: buildPublicItemImageUrl(categoryEn, img) };
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 },
items: itemsWithImage, { status: 200 }
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);
@@ -98,7 +64,9 @@ 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
@@ -1,111 +0,0 @@
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
@@ -1,150 +0,0 @@
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
@@ -1,108 +0,0 @@
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
@@ -1,139 +0,0 @@
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 },
);
}
}
@@ -1,310 +0,0 @@
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
@@ -1,338 +0,0 @@
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
@@ -1,154 +0,0 @@
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
@@ -1,112 +0,0 @@
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
@@ -1,251 +0,0 @@
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
@@ -1,330 +0,0 @@
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
@@ -1,60 +0,0 @@
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
@@ -1,52 +0,0 @@
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
@@ -1,76 +0,0 @@
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
@@ -1,230 +0,0 @@
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
@@ -1,54 +0,0 @@
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
@@ -1,94 +0,0 @@
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 },
);
}
}
@@ -1,29 +0,0 @@
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 },
);
}
}
@@ -1,198 +0,0 @@
/**
* 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
@@ -1,202 +0,0 @@
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
@@ -1,49 +0,0 @@
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
@@ -1,15 +0,0 @@
/**
* 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
@@ -1,210 +0,0 @@
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
@@ -1,136 +0,0 @@
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
@@ -1,101 +0,0 @@
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
@@ -1,243 +0,0 @@
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
@@ -1,66 +0,0 @@
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
@@ -1,193 +0,0 @@
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
@@ -1,152 +0,0 @@
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
@@ -1,178 +0,0 @@
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 },
);
}
}
+7 -6
View File
@@ -1,9 +1,10 @@
import { type NextRequest, NextResponse } from "next/server"; import { 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,
createRateLimitResponse,
getClientIP, getClientIP,
createRateLimitResponse,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
@@ -55,11 +56,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 }
); );
} }
} }
@@ -118,7 +119,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
@@ -149,7 +150,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
@@ -1,42 +0,0 @@
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 type { MetadataRoute } from "next"; import { 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
@@ -1,52 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.6 KiB

+8 -134
View File
@@ -1,20 +1,5 @@
"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,
@@ -23,28 +8,16 @@ 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 { useSession } from "@/lib/hooks/use-session";
import { cn } from "@/lib/utils";
import { LoginDialog } from "./auth/login-dialog";
import { RegisterDialog } from "./auth/register-dialog";
import { LanguageSwitcher } from "./language-switcher";
import { MessagesBell } from "./messages/MessagesBell";
import { MobileMenu } from "./mobile-menu";
import { NotificationBell } from "./notifications/NotificationBell";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { LanguageSwitcher } from "./language-switcher";
import { MobileMenu } from "./mobile-menu";
import { cn } from "@/lib/utils";
import { useTranslations, useLocale } from "next-intl";
import Link from "next/link";
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" },
@@ -54,17 +27,6 @@ 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">
@@ -81,7 +43,7 @@ export default function Header() {
{/* Desktop Navigation */} {/* Desktop Navigation */}
<NavigationMenu className="hidden md:flex"> <NavigationMenu className="hidden md:flex">
<NavigationMenuList> <NavigationMenuList>
{navLinks.map(({ href, label, highlight }) => ( {links.map(({ href, label, highlight }) => (
<NavigationMenuItem key={href}> <NavigationMenuItem key={href}>
<NavigationMenuLink asChild> <NavigationMenuLink asChild>
<Link <Link
@@ -90,7 +52,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}
@@ -105,101 +67,13 @@ 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={navLinks} /> <MobileMenu links={links} />
</div> </div>
</div> </div>
{/* Login Dialog */}
<LoginDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen} />
{/* Register Dialog */}
<RegisterDialog
open={registerDialogOpen}
onOpenChange={setRegisterDialogOpen}
/>
</header> </header>
); );
} }
-86
View File
@@ -1,86 +0,0 @@
"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
@@ -1,49 +0,0 @@
"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>
);
}
@@ -1,133 +0,0 @@
"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>
);
}
@@ -1,83 +0,0 @@
"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
@@ -1,652 +0,0 @@
"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
@@ -1,381 +0,0 @@
"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>
);
}
@@ -1,45 +0,0 @@
"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
@@ -1,227 +0,0 @@
"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
@@ -1,772 +0,0 @@
"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
@@ -1,118 +0,0 @@
"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
@@ -1,699 +0,0 @@
"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
@@ -1,117 +0,0 @@
"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
@@ -1,144 +0,0 @@
"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
@@ -1,60 +0,0 @@
"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
@@ -1,87 +0,0 @@
"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
@@ -1,119 +0,0 @@
"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>
);
}
+27 -26
View File
@@ -1,31 +1,32 @@
"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,
Coffee, DollarSign,
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 { Input } from "@/components/ui/input"; import { DonationTier, DonationResponse } from "@/types/donation";
import { Textarea } from "@/components/ui/textarea"; import { useTranslations } from "next-intl";
import type { DonationResponse, DonationTier } from "@/types/donation";
interface DonationCardProps extends DonationTier {} interface DonationCardProps extends DonationTier {}
@@ -60,7 +61,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");
@@ -93,7 +94,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();
@@ -117,7 +118,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();
@@ -345,7 +346,7 @@ export function DonationCard({
const isValidCustomAmount = () => { const isValidCustomAmount = () => {
const amount = parseFloat(customAmount); const amount = parseFloat(customAmount);
return ( return (
!Number.isNaN(amount) && !isNaN(amount) &&
amount >= customProduct.minAmount && amount >= customProduct.minAmount &&
amount <= customProduct.maxAmount amount <= customProduct.maxAmount
); );
@@ -362,12 +363,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;
@@ -395,12 +396,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;
} }
@@ -462,10 +463,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>
)} )}
@@ -484,7 +485,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 { Coffee, Heart } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Heart, Coffee } from "lucide-react";
import { BlurFade } from "@/components/ui/blur-fade";
import { useTranslations } from "next-intl";
import Link from "next/link";
export function DonationFooterCard() { export function DonationFooterCard() {
const t = useTranslations("donation"); const t = useTranslations("donation");
+88 -101
View File
@@ -1,39 +1,44 @@
"use client"; "use client";
import { import { TibiaItem } from "@/types/item";
ArrowDown, import { MagicCard } from "@/components/ui/magic-card";
ArrowUp,
Boxes,
Clock,
Coins,
Cross,
Zap as Energy,
Flame,
FlaskConical,
Gem,
Heart,
Leaf,
Minus,
ScrollText,
Shield,
Skull,
Snowflake,
Star,
Sword,
TrendingDown,
TrendingUp,
Weight,
Wrench,
Zap,
} from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { MagicCard } from "@/components/ui/magic-card";
import { PriceDisplay } from "@/components/ui/price-display"; import { PriceDisplay } from "@/components/ui/price-display";
import { formatNumber } from "@/lib/utils/formatters"; import { useTranslations } from "next-intl";
import type { TibiaItem } from "@/types/item"; import {
TrendingUp,
TrendingDown,
Sword,
Shield,
Weight,
Heart,
Star,
Clock,
Coins,
ArrowUp,
ArrowDown,
Minus,
Zap,
Flame,
Snowflake,
Zap as Energy,
Skull,
Cross,
Badge as BadgeIcon,
Boxes,
Gem,
FlaskConical,
Wrench,
ScrollText,
Leaf,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getRarityColor,
getRarityBgColor,
getRarityGlow,
} from "@/lib/currency-utils";
interface ItemCardProps { interface ItemCardProps {
item: TibiaItem; item: TibiaItem;
@@ -42,9 +47,8 @@ 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);
@@ -58,22 +62,14 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
return out; return out;
}; };
const ScrollRow = ({ const ScrollRow = ({ children, ariaLabel }: { children: React.ReactNode; ariaLabel: string }) => {
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 }>( const drag = useRef<{ active: boolean; startX: number; startLeft: number }>({ active: false, startX: 0, startLeft: 0 });
{ active: false, startX: 0, startLeft: 0 },
);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const updateButtons = () => { const updateButtons = () => {
@@ -116,9 +112,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;
@@ -184,9 +180,7 @@ 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 { try { el.releasePointerCapture(e.pointerId); } catch {}
el.releasePointerCapture(e.pointerId);
} catch {}
}; };
el.addEventListener("pointerdown", onPointerDown); el.addEventListener("pointerdown", onPointerDown);
@@ -218,9 +212,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",
@@ -255,20 +249,14 @@ 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) * (item as any).price.sell) * 100
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 ( if (!ph || typeof ph.highest !== "number" || typeof ph.lowest !== "number" || typeof ph.average !== "number") {
!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";
@@ -295,6 +283,21 @@ 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"
@@ -333,7 +336,9 @@ 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" ? null : ( {typeof CategoryIcon === "string" ? (
null
) : (
<CategoryIcon className="w-10 h-10" /> <CategoryIcon className="w-10 h-10" />
)} )}
</div> </div>
@@ -360,8 +365,7 @@ 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 = const catRaw = (catPt && String(catPt)) || (catEn && String(catEn)) || "";
(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)
: ""; : "";
@@ -384,7 +388,9 @@ 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">{item.stats.attack} ATK</span> <span className="font-medium">
{item.stats.attack} ATK
</span>
</div> </div>
)} )}
{item.stats.defense && ( {item.stats.defense && (
@@ -398,23 +404,21 @@ 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">{item.stats.armor} ARM</span> <span className="font-medium">
{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"> <span className="font-medium">Volume {(item as any).volume}</span>
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"> <span className="font-medium">Skill +{(item as any).bonus_de_skill}</span>
Skill +{(item as any).bonus_de_skill}
</span>
</div> </div>
)} )}
</div> </div>
@@ -466,8 +470,7 @@ 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(",")
@@ -479,9 +482,7 @@ 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( const match = token.match(/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/);
/^([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;
@@ -535,14 +536,10 @@ 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 = const prettyType = type.charAt(0).toUpperCase() + type.slice(1);
type.charAt(0).toUpperCase() + type.slice(1); const label = `${prettyType.replace("fisico", "Físico")}${value ? " " + value : ""}`;
const label = `${prettyType.replace("fisico", "Físico")}${value ? ` ${value}` : ""}`;
return ( return (
<div <div key={`${type}-${idx}`} className={`flex items-center gap-1 text-xs ${chipBg} ${chipText} px-2 py-1 rounded`}>
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>
@@ -568,7 +565,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();
@@ -604,7 +601,8 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</ScrollRow> </ScrollRow>
</div> </div>
); );
})()} })()
)}
{mode === "market" && item.price && ( {mode === "market" && item.price && (
<> <>
@@ -664,9 +662,7 @@ 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 <div className={`flex items-center gap-1 text-xs ${trendColor}`}>
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>
@@ -678,27 +674,21 @@ 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"> <div className="font-bold">{item.priceHistory.highest.toLocaleString()} gp</div>
{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"> <div className="font-bold">{item.priceHistory.lowest.toLocaleString()} gp</div>
{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"> <div className="font-bold">{item.priceHistory.average.toLocaleString()} gp</div>
{formatNumber(item.priceHistory.average, locale)} gp
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -713,10 +703,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
> >
{t("viewDetails")} {t("viewDetails")}
</Button> </Button>
<Button <Button className="w-full bg-green-600 hover:bg-green-700 text-white" size="sm">
className="w-full bg-green-600 hover:bg-green-700 text-white"
size="sm"
>
{t("buyNow")} {t("buyNow")}
</Button> </Button>
</div> </div>
+8 -19
View File
@@ -1,9 +1,8 @@
"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,
@@ -11,6 +10,7 @@ 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,12 +22,6 @@ 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
@@ -36,17 +30,7 @@ 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>
@@ -71,3 +55,8 @@ export function LanguageSwitcher() {
</DropdownMenu> </DropdownMenu>
); );
} }
+22 -12
View File
@@ -1,10 +1,20 @@
"use client"; "use client";
import { Filter, Search, SortAsc, SortDesc, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Search,
Filter,
SortAsc,
SortDesc,
X,
Star,
TrendingUp,
Clock,
} from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -13,7 +23,6 @@ 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;
@@ -39,7 +48,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: [],
@@ -133,9 +142,7 @@ export function MarketplaceFilters({
}; };
const activeFiltersCount = const activeFiltersCount =
activeFilters.rarity.length + activeFilters.rarity.length + activeFilters.category.length + (activeFilters.vocation ? 1 : 0);
activeFilters.category.length +
(activeFilters.vocation ? 1 : 0);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -240,9 +247,7 @@ export function MarketplaceFilters({
const newFilters = { const newFilters = {
...activeFilters, ...activeFilters,
vocation: vocation:
activeFilters.vocation === option.value activeFilters.vocation === option.value ? null : option.value,
? null
: option.value,
}; };
setActiveFilters(newFilters); setActiveFilters(newFilters);
onFilter(newFilters); onFilter(newFilters);
@@ -317,7 +322,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
@@ -357,3 +362,8 @@ export function MarketplaceFilters({
</div> </div>
); );
} }
File diff suppressed because it is too large. Load diff
@@ -1,275 +0,0 @@
"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
@@ -1,111 +0,0 @@
"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
@@ -1,408 +0,0 @@
"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
@@ -1,486 +0,0 @@
"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>
);
}
@@ -1,524 +0,0 @@
"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
@@ -1,239 +0,0 @@
"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>
);
}
@@ -1,610 +0,0 @@
"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>
);
}
@@ -1,294 +0,0 @@
"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