diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 846c034..220c68d 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono, Nunito_Sans } from "next/font/google"; import { NextIntlClientProvider } from "next-intl"; import { getMessages } from "next-intl/server"; import { ThemeProvider } from "@/components/theme-provider"; +import { QueryProvider } from "@/lib/providers/query-provider"; import "../globals.css"; const nunitoSans = Nunito_Sans({ @@ -161,11 +162,13 @@ export default async function LocaleLayout({ - - - {children} - - + + + + {children} + + + ); diff --git a/app/[locale]/market/MarketClient.tsx b/app/[locale]/market/MarketClient.tsx new file mode 100644 index 0000000..4b68d01 --- /dev/null +++ b/app/[locale]/market/MarketClient.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Search, SortAsc, SortDesc, Filter, X, Grid, List } from "lucide-react"; +import { useMarketTelemetry } from "@/lib/hooks/use-telemetry"; +import { AdCard } from "@/components/marketplace/AdCard"; +import { CreateAdModal } from "@/components/marketplace/CreateAdModal"; +import { MyAdsPanel } from "@/components/marketplace/MyAdsPanel"; +import { useSession } from "@/lib/hooks/use-session"; +import { useListings, useInvalidateListings } from "@/lib/hooks/use-listings"; + +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 [page, setPage] = useState(initial.page || 1); + const [pageSize] = useState(initial.pageSize || 20); + const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); + + // Filtros + const [searchQuery, setSearchQuery] = useState(""); + const [serverName, setServerName] = useState(""); + const [sortBy, setSortBy] = useState("created_at"); + const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC"); + const [priceMin, setPriceMin] = useState(""); + const [priceMax, setPriceMax] = useState(""); + const [showCreateModal, setShowCreateModal] = useState(false); + const [activeTab, setActiveTab] = useState("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) => { + 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 ( +
+ {/* Header com botão de criar anúncio */} +
+
+

Marketplace

+

+ {activeTab === "explore" + ? `${total} ${total === 1 ? "anúncio" : "anúncios"} disponíveis` + : "Gerencie seus anúncios"} +

+
+ {user && ( + + )} +
+ + {/* Tabs para Explorar e Meus Anúncios */} + + + Explorar + {user && Meus Anúncios} + + + {/* Aba: Explorar Anúncios */} + + + {/* Barra de busca */} +
+ + handleSearch(e.target.value)} + className="pl-10 pr-4" + /> +
+ + {/* Filtros e controles */} +
+ {/* Filtro por servidor */} + { + setServerName(e.target.value); + handleFilter({ server: e.target.value }); + }} + className="w-48" + /> + + {/* Filtro por preço mínimo */} + { + setPriceMin(e.target.value); + handleFilter({ priceMin: e.target.value ? parseFloat(e.target.value) : undefined }); + }} + className="w-32" + /> + + {/* Filtro por preço máximo */} + { + setPriceMax(e.target.value); + handleFilter({ priceMax: e.target.value ? parseFloat(e.target.value) : undefined }); + }} + className="w-32" + /> + + {/* Ordenação */} + + + + + + Ordenar por + + {sortOptions.map((option) => { + const Icon = option.icon; + return ( + handleSort(option.value)} + className="flex items-center gap-2" + > + + {option.label} + {sortBy === option.value && ( +
+ )} + + ); + })} + + + + {/* Toggle de visualização */} +
+ + +
+ + {/* Limpar filtros */} + {activeFiltersCount > 0 && ( + + )} +
+ + {/* Badges de filtros ativos */} + {activeFiltersCount > 0 && ( +
+ {searchQuery && ( + + Busca: {searchQuery} + { + setSearchQuery(""); + setPage(1); + }} + /> + + )} + {serverName && ( + + Servidor: {serverName} + { + setServerName(""); + setPage(1); + }} + /> + + )} + {(priceMin || priceMax) && ( + + Preço: {priceMin || "0"} - {priceMax || "∞"} + { + setPriceMin(""); + setPriceMax(""); + setPage(1); + }} + /> + + )} +
+ )} + + {/* Listagem de anúncios */} + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : listings.length === 0 ? ( +
+

+ Nenhum anúncio encontrado +

+

+ Tente ajustar os filtros ou criar um novo anúncio +

+
+ ) : ( + <> +
+ {listings.map((listing) => ( + + ))} +
+ + {/* Paginação */} + {totalPages > 1 && ( +
+ + + Página {page} de {totalPages} + + +
+ )} + + )} + + {/* Modal de criação de anúncio */} + {showCreateModal && ( + { + 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"); + } + }} + /> + )} + + + {/* Aba: Meus Anúncios */} + {user && ( + + + + )} + +
+ ); +} + diff --git a/app/[locale]/market/analytics/page.tsx b/app/[locale]/market/analytics/page.tsx new file mode 100644 index 0000000..99e3eb0 --- /dev/null +++ b/app/[locale]/market/analytics/page.tsx @@ -0,0 +1,41 @@ +import Header from "@/components/Header"; +import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; +import { AnalyticsDashboard } from "@/components/marketplace/AnalyticsDashboard"; +import { requireAuth } from "@/lib/auth/middleware"; +import { NextRequest } from "next/server"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + 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 ( +
+
+
+ +
+
+ ); +} + diff --git a/app/[locale]/market/page.tsx b/app/[locale]/market/page.tsx index 654557b..a1beea1 100644 --- a/app/[locale]/market/page.tsx +++ b/app/[locale]/market/page.tsx @@ -1,7 +1,8 @@ import Header from "@/components/Header"; -import { UnderConstruction } from "@/components/under-construction"; import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; +import MarketClient from "./MarketClient"; +import { getActiveListings } from "@/lib/prisma/queries/listings"; export async function generateMetadata({ params, @@ -9,7 +10,6 @@ export async function generateMetadata({ params: Promise<{ locale: string }>; }): Promise { const { locale } = await params; - const t = await getTranslations({ locale, namespace: "underConstruction" }); const titles = { pt: "Mercado - Todos os Itens de Tibia", @@ -37,23 +37,46 @@ export async function generateMetadata({ export default async function MarketPage({ params, + searchParams, }: { params: Promise<{ locale: string }>; + searchParams?: Promise>; }) { const { locale } = await params; - const t = await getTranslations({ - locale, - namespace: "underConstruction.market", + const sp = (await searchParams) ?? {}; + + const page = typeof sp.page === "string" ? parseInt(sp.page, 10) : 1; + const pageSize = typeof sp.pageSize === "string" ? parseInt(sp.pageSize, 10) : 20; + + // Buscar anúncios ativos + const listingsData = await getActiveListings({ + search: typeof sp.search === "string" ? sp.search : undefined, + server_name: typeof sp.server_name === "string" ? sp.server_name : undefined, + page, + pageSize, + sortBy: (typeof sp.sortBy === "string" ? sp.sortBy : "created_at") as + | "created_at" + | "price_pix" + | "price_tibia_coins" + | "price_gold_coins" + | "views_count", + sortOrder: (typeof sp.sortOrder === "string" ? sp.sortOrder : "DESC") as "ASC" | "DESC", }); return (
- +
+ +
); } diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index ce0cb15..979e5a4 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -15,7 +15,7 @@ import { getUserByEmail, getUserByUsername, updateLastLogin, -} from "@/lib/db/queries/users"; +} from "@/lib/prisma/queries/users"; import { setSession } from "@/lib/redis/session"; import { randomUUID } from "crypto"; diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index 0083505..61b9373 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { secureLogger } from "@/lib/secure-logger"; import { requireAuth } from "@/lib/auth/middleware"; -import { getUserById } from "@/lib/db/queries/users"; -import { getUserCharacters } from "@/lib/db/queries/characters"; +import { getUserById } from "@/lib/prisma/queries/users"; +import { getUserCharacters } from "@/lib/prisma/queries/characters"; export async function GET(request: NextRequest) { try { diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index b5feb39..70905cd 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -14,14 +14,15 @@ import { import { getUserByEmail, getUserByUsername, -} from "@/lib/db/queries/users"; +} from "@/lib/prisma/queries/users"; import { isServerActive, incrementUsersForServers, -} from "@/lib/db/queries/servers"; +} from "@/lib/prisma/queries/servers"; import { setSession } from "@/lib/redis/session"; import { randomUUID } from "crypto"; -import { getPool } from "@/lib/db"; +import { createUser } from "@/lib/prisma/queries/users"; +import { createCharacter } from "@/lib/prisma/queries/characters"; export async function POST(request: NextRequest) { try { @@ -110,90 +111,82 @@ export async function POST(request: NextRequest) { // Hash da senha const passwordHash = await hashPassword(validatedData.password); - // Criar usuário e personagens em transação atômica - const pool = getPool(); - const client = await pool.connect(); - + // Criar usuário e personagens em transação atômica usando Prisma + const { prisma } = await import("@/lib/prisma/app"); + let newUser: { userId: number; username: string; email: string }; const createdCharacters: Array<{ characterName: string; serverName: string }> = []; try { - await client.query("BEGIN"); + // Usar transação Prisma + 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, + }, + }); - // Criar usuário - const userResult = await client.query<{ - user_id: number; - username: string; - email: string; - }>( - ` - INSERT INTO users (username, email, password_hash, first_name, last_name, phone, cpf) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING user_id, username, email - `, - [ - validatedData.username, - validatedData.email, - passwordHash, - validatedData.firstName, - validatedData.lastName, - validatedData.phone || null, - null, // CPF não é mais obrigatório - ] - ); + newUser = { + userId: user.user_id, + username: user.username, + email: user.email, + }; - newUser = { - userId: userResult.rows[0].user_id, - username: userResult.rows[0].username, - email: userResult.rows[0].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; - // 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: newUser.userId }, + data: { is_main: false }, + }); + } - // Se este personagem será main e não é o primeiro, desmarcar outros - if (isMain && i > 0) { - await client.query( - ` - UPDATE user_characters - SET is_main = FALSE - WHERE user_id = $1 - `, - [newUser.userId] - ); + // Inserir personagem + const character = await tx.userCharacter.create({ + data: { + user_id: newUser.userId, + character_name: serverData.characterName, + server_name: serverData.serverName, + is_main: isMain, + }, + }); + + createdCharacters.push({ + characterName: character.character_name, + serverName: character.server_name, + }); } - // Inserir personagem - const charResult = await client.query<{ - character_id: number; - character_name: string; - server_name: string; - }>( - ` - INSERT INTO user_characters (user_id, character_name, server_name, is_main) - VALUES ($1, $2, $3, $4) - RETURNING character_id, character_name, server_name - `, - [newUser.userId, serverData.characterName, serverData.serverName, isMain] - ); - - createdCharacters.push({ - characterName: charResult.rows[0].character_name, - serverName: charResult.rows[0].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(), + }, }); - } - - // Incrementar total_users dos servidores - await incrementUsersForServers(uniqueServerNames); - - await client.query("COMMIT"); + }); } catch (error) { - await client.query("ROLLBACK"); + // Prisma já faz rollback automático em caso de erro throw error; - } finally { - client.release(); } // Gerar tokens diff --git a/app/api/images/route.ts b/app/api/images/route.ts index e25683c..f95d027 100644 --- a/app/api/images/route.ts +++ b/app/api/images/route.ts @@ -1,14 +1,105 @@ import { NextRequest } from "next/server"; import fs from "node:fs"; import path from "node:path"; +import { getItemById } from "@/lib/items-db"; +import { getItemByIdUnified } from "@/lib/db/queries/items"; +import { buildItemImagePath } from "@/lib/item-images"; export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const relPath = searchParams.get("path") || ""; + const itemId = searchParams.get("item"); + const itemName = searchParams.get("name"); // Nome do item para validação (opcional) + + let imagePath: string | null = null; + + // Se foi fornecido um item ID, buscar o item no banco + if (itemId) { + try { + const itemIdNum = parseInt(itemId, 10); + if (!isNaN(itemIdNum)) { + // Tentar primeiro buscar na tabela unificada PostgreSQL + // Se a tabela não existir, retornará null silenciosamente + let item: any = null; + item = await getItemByIdUnified(itemIdNum); + + // Se encontrou na tabela unificada, validar o nome se fornecido + if (item && itemName && item.item_name) { + if (item.item_name.toLowerCase() !== itemName.toLowerCase()) { + // Nome não bate, ignorar este resultado + item = null; + } + } + + // Se não encontrou na tabela unificada, tentar SQLite legado + // Passar itemName para buscar por ID E NOME (evita ambiguidade) + if (!item) { + item = getItemById(itemIdNum, itemName || undefined); + } + + if (item) { + // PRIORIDADE: 1. image_local_path (local), 2. image_url (externa), 3. image (fallback) + const imageLocalPath = item.image_local_path || ""; + const imageUrl = item.image_url || ""; + const image = item.image || ""; + + // Tentar primeiro a imagem local + if (imageLocalPath) { + const normalizedPath = imageLocalPath.replace(/\\/g, "/"); + const category = (item.categoria || item.category || "other").toLowerCase(); + + // Se já for um caminho completo (começa com data/), usar diretamente + if (normalizedPath.startsWith("data/")) { + imagePath = normalizedPath; + } else { + // Construir o caminho usando a função helper + imagePath = buildItemImagePath(category, normalizedPath); + } + + // Verificar se o arquivo existe antes de usar + if (imagePath) { + const baseDir = path.join(process.cwd(), "data", "images"); + const resolved = path.join(process.cwd(), imagePath); + + if (resolved.startsWith(baseDir)) { + try { + await fs.promises.access(resolved); + // Arquivo existe, usar este caminho + } catch { + // Arquivo não existe, tentar fallback externo + imagePath = null; + } + } + } + } + + // Se não encontrou imagem local ou não existe, usar URL externa como fallback + if (!imagePath && (imageUrl || image)) { + const externalUrl = imageUrl || image; + if (externalUrl.startsWith("http://") || externalUrl.startsWith("https://")) { + return Response.redirect(externalUrl, 302); + } + } + } + } + } catch (error) { + console.error("[api/images] Erro ao buscar item:", error); + } + } else if (relPath) { + // Se foi fornecido um path, usar diretamente + imagePath = relPath; + } + + if (!imagePath) { + // Se não foi possível construir o caminho, retornar 404 ao invés de 400 + // para evitar erros visíveis no console do navegador + return new Response("Not Found", { status: 404 }); + } // Permitir apenas caminhos sob data/images para segurança const baseDir = path.join(process.cwd(), "data", "images"); - const resolved = path.join(process.cwd(), relPath); + const resolved = path.join(process.cwd(), imagePath); + if (!resolved.startsWith(baseDir)) { return new Response("Forbidden", { status: 403 }); } @@ -27,7 +118,9 @@ export async function GET(req: NextRequest) { ? "image/jpeg" : "application/octet-stream"; return new Response(file, { status: 200, headers: { "Content-Type": contentType } }); - } catch { + } 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 }); } } diff --git a/app/api/listings/[id]/activate/route.ts b/app/api/listings/[id]/activate/route.ts new file mode 100644 index 0000000..81135ea --- /dev/null +++ b/app/api/listings/[id]/activate/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getListingById, updateListing } from "@/lib/prisma/queries/listings"; + +/** + * 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 (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 } + ); + } +} + diff --git a/app/api/listings/[id]/bid/route.ts b/app/api/listings/[id]/bid/route.ts new file mode 100644 index 0000000..28ba3a2 --- /dev/null +++ b/app/api/listings/[id]/bid/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { placeBid } from "@/lib/prisma/queries/listings"; +import { createNotification } from "@/lib/prisma/queries/notifications"; + +/** + * POST /api/listings/[id]/bid + * Coloca um lance em um leilão + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const listingId = parseInt(params.id, 10); + if (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 } + ); + } + + // Criar notificação para o vendedor + await createNotification({ + user_id: result.listing!.seller_id, + notification_type: "bid", + title: "Novo lance!", + message: `Novo lance de R$ ${amount.toFixed(2)} no item "${result.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 "${result.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: result.listing!.current_bid_amount ? Number(result.listing!.current_bid_amount) : null, + min_increment: result.listing!.min_bid_increment ? Number(result.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 } + ); + } +} + diff --git a/app/api/listings/[id]/bids/route.ts b/app/api/listings/[id]/bids/route.ts new file mode 100644 index 0000000..5bf5e0b --- /dev/null +++ b/app/api/listings/[id]/bids/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getListingById } from "@/lib/prisma/queries/listings"; +import { prisma } from "@/lib/prisma/app"; +import { trackEvent } from "@/lib/prisma/queries/telemetry"; + +/** + * GET /api/listings/[id]/bids + * Lista lances de um anúncio + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + + const listingId = parseInt(params.id, 10); + if (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 } + ); + } +} + diff --git a/app/api/listings/[id]/favorite/route.ts b/app/api/listings/[id]/favorite/route.ts new file mode 100644 index 0000000..dc06a3e --- /dev/null +++ b/app/api/listings/[id]/favorite/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getListingById } from "@/lib/prisma/queries/listings"; +import { isFavorite, toggleFavorite } from "@/lib/prisma/queries/favorites"; + +/** + * 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 (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 (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 } + ); + } +} + diff --git a/app/api/listings/[id]/intentions/[intentionId]/route.ts b/app/api/listings/[id]/intentions/[intentionId]/route.ts new file mode 100644 index 0000000..9f8aed4 --- /dev/null +++ b/app/api/listings/[id]/intentions/[intentionId]/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getListingById } from "@/lib/prisma/queries/listings"; +import { + getPurchaseIntentionById, + updatePurchaseIntentionStatus, +} from "@/lib/prisma/queries/purchase-intentions"; +import { createNotification } from "@/lib/prisma/queries/notifications"; + +/** + * GET /api/listings/[id]/intentions/[intentionId] + * Busca uma intenção específica + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string; intentionId: string } } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + + const intentionId = parseInt(params.intentionId, 10); + if (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: { id: string; intentionId: string } } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + + const intentionId = parseInt(params.intentionId, 10); + if (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; + + 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, + seller_response + ); + + // 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 } + ); + } +} + diff --git a/app/api/listings/[id]/intentions/route.ts b/app/api/listings/[id]/intentions/route.ts new file mode 100644 index 0000000..a529e23 --- /dev/null +++ b/app/api/listings/[id]/intentions/route.ts @@ -0,0 +1,242 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getListingById } from "@/lib/prisma/queries/listings"; +import { + createPurchaseIntention, + getPurchaseIntentionsByListing, + hasPendingIntention, +} from "@/lib/prisma/queries/purchase-intentions"; +import { createNotification } from "@/lib/prisma/queries/notifications"; +import { trackEvent } from "@/lib/prisma/queries/telemetry"; + +/** + * POST /api/listings/[id]/intentions + * Cria uma nova intenção de compra + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const listingId = parseInt(params.id, 10); + if (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 } = 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 } + ); + } + + // Verificar se já existe uma intenção pendente + const alreadyHasPending = await hasPendingIntention(listingId, auth.userId); + if (alreadyHasPending) { + return NextResponse.json( + { success: false, error: "Você já possui uma intenção de compra pendente para este anúncio" }, + { 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, + }); + + // Criar notificação para o vendedor + await createNotification({ + user_id: listing.seller_id, + notification_type: "purchase", + title: "Nova intenção de compra!", + message: `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, + }, + }); + + 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: { id: string } } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const listingId = parseInt(params.id, 10); + if (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 } + ); + } +} + diff --git a/app/api/listings/[id]/mark-sold/route.ts b/app/api/listings/[id]/mark-sold/route.ts new file mode 100644 index 0000000..a2eb647 --- /dev/null +++ b/app/api/listings/[id]/mark-sold/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getListingById, updateListing } from "@/lib/prisma/queries/listings"; + +/** + * 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 (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 } + ); + } +} + diff --git a/app/api/listings/[id]/pause/route.ts b/app/api/listings/[id]/pause/route.ts new file mode 100644 index 0000000..a9786e8 --- /dev/null +++ b/app/api/listings/[id]/pause/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getListingById, updateListing } from "@/lib/prisma/queries/listings"; + +/** + * 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 (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 } + ); + } +} + diff --git a/app/api/listings/[id]/purchase/route.ts b/app/api/listings/[id]/purchase/route.ts new file mode 100644 index 0000000..ce1d0ef --- /dev/null +++ b/app/api/listings/[id]/purchase/route.ts @@ -0,0 +1,166 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getListingById } from "@/lib/prisma/queries/listings"; +import { createNotification } from "@/lib/prisma/queries/notifications"; +import { + createPurchaseIntention, + hasPendingIntention, +} from "@/lib/prisma/queries/purchase-intentions"; +import { trackEvent } from "@/lib/prisma/queries/telemetry"; + +/** + * POST /api/listings/[id]/purchase + * Cria uma intenção de compra para um item anunciado + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const listingId = parseInt(params.id, 10); + if (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 } = body; // 'pix' | 'tc' | 'gp' | 'trade' + + 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 } + ); + } + + // Verificar se já existe uma intenção pendente + const alreadyHasPending = await hasPendingIntention(listingId, auth.userId); + if (alreadyHasPending) { + return NextResponse.json( + { success: false, error: "Você já possui uma intenção de compra pendente para este anúncio" }, + { 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, + }); + + // Criar notificação para o vendedor + await createNotification({ + user_id: listing.seller_id, + notification_type: "purchase", + title: "Nova intenção de compra!", + message: `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, + }, + }); + + 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 } + ); + } +} + diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts index e9a7f6f..b60e807 100644 --- a/app/api/listings/[id]/route.ts +++ b/app/api/listings/[id]/route.ts @@ -5,7 +5,7 @@ import { getListingById, updateListing, deleteListing, -} from "@/lib/db/queries/listings"; +} from "@/lib/prisma/queries/listings"; import { updateListingSchema } from "@/lib/validation-schemas"; /** diff --git a/app/api/listings/[id]/seller/route.ts b/app/api/listings/[id]/seller/route.ts new file mode 100644 index 0000000..481a01d --- /dev/null +++ b/app/api/listings/[id]/seller/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { getListingById } from "@/lib/prisma/queries/listings"; +import { getMainCharacterByUserId } from "@/lib/prisma/queries/characters"; + +/** + * GET /api/listings/[id]/seller + * Retorna informações do vendedor (nome do personagem) + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const listingId = parseInt(params.id, 10); + if (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 } + ); + } +} + diff --git a/app/api/listings/[id]/views/route.ts b/app/api/listings/[id]/views/route.ts index a2afaa6..392f141 100644 --- a/app/api/listings/[id]/views/route.ts +++ b/app/api/listings/[id]/views/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { secureLogger } from "@/lib/secure-logger"; -import { incrementViews } from "@/lib/db/queries/listings"; +import { incrementViews } from "@/lib/prisma/queries/listings"; /** * POST /api/listings/[id]/views diff --git a/app/api/listings/my/route.ts b/app/api/listings/my/route.ts new file mode 100644 index 0000000..9f36323 --- /dev/null +++ b/app/api/listings/my/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getListings } from "@/lib/prisma/queries/listings"; + +/** + * 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 } + ); + } +} + diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index f3bea85..cf13391 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -11,8 +11,8 @@ import { getListings, createListing, getActiveListings, -} from "@/lib/db/queries/listings"; -import { isServerActive } from "@/lib/db/queries/servers"; +} from "@/lib/prisma/queries/listings"; +import { isServerActive } from "@/lib/prisma/queries/servers"; /** * GET /api/listings @@ -162,6 +162,7 @@ export async function POST(request: NextRequest) { const listing = await createListing({ seller_id: auth.userId, item_id: validatedData.item_id, + item_id_unified: validatedData.item_id_unified, item_name: validatedData.item_name, quantity: validatedData.quantity, server_name: validatedData.server_name, @@ -171,6 +172,9 @@ export async function POST(request: NextRequest) { 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", { diff --git a/app/api/notifications/[id]/read/route.ts b/app/api/notifications/[id]/read/route.ts new file mode 100644 index 0000000..946591a --- /dev/null +++ b/app/api/notifications/[id]/read/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { markAsRead } from "@/lib/prisma/queries/notifications"; + +/** + * POST /api/notifications/[id]/read + * Marca uma notificação específica como lida + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const notificationId = parseInt(params.id, 10); + if (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 } + ); + } +} + diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..cc7e937 --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { + getUserNotifications, + getUnreadCount, + markAsRead, + markAllAsRead, +} from "@/lib/prisma/queries/notifications"; + +/** + * 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 } + ); + } +} + diff --git a/app/api/notifications/unread-count/route.ts b/app/api/notifications/unread-count/route.ts new file mode 100644 index 0000000..bd5c698 --- /dev/null +++ b/app/api/notifications/unread-count/route.ts @@ -0,0 +1,30 @@ +import { 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 } + ); + } +} + diff --git a/app/api/servers/route.ts b/app/api/servers/route.ts index b7c4e9d..2616359 100644 --- a/app/api/servers/route.ts +++ b/app/api/servers/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getActiveServers } from "@/lib/db/queries/servers"; +import { getActiveServers } from "@/lib/prisma/queries/servers"; import { secureLogger } from "@/lib/secure-logger"; /** diff --git a/app/api/telemetry/route.ts b/app/api/telemetry/route.ts new file mode 100644 index 0000000..b6e3842 --- /dev/null +++ b/app/api/telemetry/route.ts @@ -0,0 +1,240 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; +import { secureLogger } from "@/lib/secure-logger"; +import { + telemetryEventSchema, + telemetryBatchSchema, +} from "@/lib/validation-schemas"; +import { optionalAuth } from "@/lib/auth/middleware"; +import { + trackEvent, + trackEventsBatch, + getAdAnalytics, + getMarketplaceStats, +} from "@/lib/prisma/queries/telemetry"; + +/** + * 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, + 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, + 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 (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 } + ); + } +} + diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index 37c11ad..9a76a37 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { secureLogger } from "@/lib/secure-logger"; -import { getUserById } from "@/lib/db/queries/users"; +import { getUserById } from "@/lib/prisma/queries/users"; export async function GET( request: NextRequest, diff --git a/app/api/users/characters/[id]/route.ts b/app/api/users/characters/[id]/route.ts index ed63b7e..c7fd622 100644 --- a/app/api/users/characters/[id]/route.ts +++ b/app/api/users/characters/[id]/route.ts @@ -5,7 +5,7 @@ import { updateCharacter, deleteCharacter, getCharacterById, -} from "@/lib/db/queries/characters"; +} from "@/lib/prisma/queries/characters"; import { characterSchema } from "@/lib/validation-schemas"; export async function PUT( diff --git a/app/api/users/characters/route.ts b/app/api/users/characters/route.ts index 11c4655..f12c748 100644 --- a/app/api/users/characters/route.ts +++ b/app/api/users/characters/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { secureLogger } from "@/lib/secure-logger"; import { requireAuth } from "@/lib/auth/middleware"; -import { getUserCharacters, createCharacter } from "@/lib/db/queries/characters"; +import { getUserCharacters, createCharacter } from "@/lib/prisma/queries/characters"; import { characterSchema } from "@/lib/validation-schemas"; export async function GET(request: NextRequest) { diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts index e2765d2..0888702 100644 --- a/app/api/users/me/route.ts +++ b/app/api/users/me/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { secureLogger } from "@/lib/secure-logger"; import { requireAuth } from "@/lib/auth/middleware"; -import { getUserById, updateUser } from "@/lib/db/queries/users"; -import { getUserCharacters } from "@/lib/db/queries/characters"; +import { getUserById, updateUser } from "@/lib/prisma/queries/users"; +import { getUserCharacters } from "@/lib/prisma/queries/characters"; import { updateUserSchema } from "@/lib/validation-schemas"; export async function GET(request: NextRequest) { diff --git a/components/Header.tsx b/components/Header.tsx index e443d55..5b7f95a 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -11,6 +11,7 @@ import { WordRotate } from "@/components/ui/word-rotate"; import { ThemeToggle } from "./theme-toggle"; import { LanguageSwitcher } from "./language-switcher"; import { MobileMenu } from "./mobile-menu"; +import { NotificationBell } from "./notifications/NotificationBell"; import { LoginDialog } from "./auth/login-dialog"; import { RegisterDialog } from "./auth/register-dialog"; import { Button } from "@/components/ui/button"; @@ -96,6 +97,9 @@ export default function Header() { + {/* Notifications */} + {mounted && isAuthenticated && } + {/* Auth Section */} {!mounted ? ( // Placeholder durante SSR para evitar mismatch diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx index ca2461c..288f0b3 100644 --- a/components/language-switcher.tsx +++ b/components/language-switcher.tsx @@ -11,6 +11,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; const languages = [ { code: "pt", name: "Português", flag: "🇧🇷" }, @@ -22,6 +23,12 @@ export function LanguageSwitcher() { const router = useRouter(); const pathname = usePathname(); const t = useTranslations("header"); + const [mounted, setMounted] = useState(false); + + // Evitar hydration mismatch com Radix UI + useEffect(() => { + setMounted(true); + }, []); const handleLanguageChange = (newLocale: string) => { // Remove the current locale from the pathname @@ -32,6 +39,16 @@ export function LanguageSwitcher() { const currentLanguage = languages.find((lang) => lang.code === locale); + // Renderizar placeholder durante SSR para evitar mismatch + if (!mounted) { + return ( + + ); + } + return ( diff --git a/components/marketplace/AdCard.tsx b/components/marketplace/AdCard.tsx new file mode 100644 index 0000000..bf09121 --- /dev/null +++ b/components/marketplace/AdCard.tsx @@ -0,0 +1,799 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Heart, Clock, Eye, Star, MapPin, DollarSign, Coins, RefreshCw, ShoppingCart, TrendingUp, User } from "lucide-react"; +import { useAdTracking, useMarketTelemetry } from "@/lib/hooks/use-telemetry"; +import { useSession } from "@/lib/hooks/use-session"; +import { useQueryClient } from "@tanstack/react-query"; +import Image from "next/image"; +import { PurchaseModal } from "./PurchaseModal"; +import { BidModal } from "./BidModal"; +import { SellerInfo } from "./SellerInfo"; +import { ListingDetailsModal } from "./ListingDetailsModal"; + +// Função auxiliar para formatar tempo relativo +function formatTimeAgo(date: Date): string { + const seconds = Math.floor((new Date().getTime() - 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); + 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"}`; +} + +interface Listing { + listing_id: number; + seller_id: number; + item_id: number; // ID legado (SQLite) - mantido para compatibilidade + item_name: string; + quantity: number; + server_name: string; + price_pix: number | string | null; // PostgreSQL DECIMAL pode vir como string + price_tibia_coins: number | string | null; // PostgreSQL INTEGER pode vir como string + price_gold_coins: number | string | null; // PostgreSQL BIGINT pode vir como string + 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; + // Campos de leilão + 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; + // Campos do JOIN com items (unificado) + item_id_unified?: number | null; // ID unificado da tabela items + image_local_path?: string | null; + image_url?: string | null; + item_category?: string | null; + // Campo do vendedor + seller_character_name?: string | null; +} + +interface PriceInfo { + type: "pix" | "tc" | "gp" | "trade"; + label: string; + value: string; +} + +interface AdCardProps { + listing: Listing; + viewMode?: "grid" | "list"; +} + +export function AdCard({ listing, viewMode = "grid" }: AdCardProps) { + const { user } = useSession(); + const queryClient = useQueryClient(); + const { trackAdClick, trackFavorite } = useMarketTelemetry(); + const [isFavorited, setIsFavorited] = useState(false); + const [favoritesCount, setFavoritesCount] = useState(listing.favorites_count || 0); + const [showPurchaseModal, setShowPurchaseModal] = useState(false); + const [showBidModal, setShowBidModal] = useState(false); + const [showDetailsModal, setShowDetailsModal] = useState(false); + + const invalidateCache = () => { + queryClient.invalidateQueries({ queryKey: ["listings"] }); + queryClient.invalidateQueries({ queryKey: ["my-listings"] }); + }; + + // Tracking automático de visualização + useAdTracking(listing.listing_id); + + // Verificar se está favoritado + useEffect(() => { + if (user?.userId && listing.listing_id) { + fetch(`/api/listings/${listing.listing_id}/favorite`) + .then((res) => { + if (res.ok) { + return res.json(); + } + return null; + }) + .then((data) => { + if (data?.success) { + setIsFavorited(data.favorited); + } + }) + .catch(() => { + // Silenciar erro para não gerar mensagens no console + }); + } + }, [user?.userId, listing.listing_id]); + + const handleFavorite = async () => { + if (!user) return; + + const action = isFavorited ? "unfavorite" : "favorite"; + trackFavorite(listing.listing_id, action); + + try { + const res = await fetch(`/api/listings/${listing.listing_id}/favorite`, { + method: "POST", + }); + + if (res.ok) { + const data = await res.json(); + setIsFavorited(data.favorited); + setFavoritesCount((prev) => (data.favorited ? prev + 1 : Math.max(0, prev - 1))); + } + } catch (error) { + console.error("Erro ao favoritar:", error); + } + }; + + const handleClick = (e?: React.MouseEvent) => { + if (e) { + e.stopPropagation(); + } + trackAdClick(listing.listing_id); + setShowDetailsModal(true); + }; + + const handlePause = async () => { + try { + const res = await fetch(`/api/listings/${listing.listing_id}/pause`, { + method: "POST", + }); + if (res.ok) { + setShowDetailsModal(false); + invalidateCache(); + } + } catch (error) { + console.error("Erro ao pausar anúncio:", error); + } + }; + + const handleActivate = async () => { + try { + const res = await fetch(`/api/listings/${listing.listing_id}/activate`, { + method: "POST", + }); + if (res.ok) { + setShowDetailsModal(false); + invalidateCache(); + } else { + const data = await res.json(); + alert(data.error || "Erro ao reativar anúncio"); + } + } catch (error) { + console.error("Erro ao reativar anúncio:", error); + alert("Erro ao reativar anúncio"); + } + }; + + const handleCancel = async () => { + if (!confirm("Tem certeza que deseja cancelar este anúncio?")) { + return; + } + try { + const res = await fetch(`/api/listings/${listing.listing_id}`, { + method: "DELETE", + }); + if (res.ok) { + setShowDetailsModal(false); + invalidateCache(); + } else { + const data = await res.json(); + alert(data.error || "Erro ao cancelar anúncio"); + } + } catch (error) { + console.error("Erro ao cancelar anúncio:", error); + alert("Erro ao cancelar anúncio"); + } + }; + + const getAvailablePrices = (): PriceInfo[] => { + const prices: PriceInfo[] = []; + + // PIX + if (listing.price_pix) { + const price = typeof listing.price_pix === 'string' + ? parseFloat(listing.price_pix) + : listing.price_pix; + if (!isNaN(price)) { + prices.push({ + type: "pix", + label: "PIX", + value: `R$ ${price.toFixed(2)}`, + }); + } + } + + // Tibia Coins + if (listing.price_tibia_coins) { + const price = typeof listing.price_tibia_coins === 'string' + ? parseInt(listing.price_tibia_coins, 10) + : listing.price_tibia_coins; + if (!isNaN(price)) { + prices.push({ + type: "tc", + label: "TC", + value: `${price.toLocaleString()} TC`, + }); + } + } + + // Gold Coins + if (listing.price_gold_coins) { + const price = typeof listing.price_gold_coins === 'string' + ? parseInt(listing.price_gold_coins, 10) + : listing.price_gold_coins; + if (!isNaN(price)) { + prices.push({ + type: "gp", + label: "GP", + value: `${price.toLocaleString()} GP`, + }); + } + } + + // Item de Troca + if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) { + const quantity = listing.trade_item_quantity; + prices.push({ + type: "trade", + label: "Troca", + value: `Troca por ${quantity}x ${listing.trade_item_name}`, + }); + } + + return prices; + }; + + const timeAgo = formatTimeAgo(new Date(listing.created_at)); + const availablePrices = getAvailablePrices(); + const isSold = listing.listing_status === "sold"; + + // Função para obter URL da imagem do item + const getItemImageUrl = (): string => { + // Prioridade 1: image_url (URL externa) se disponível + if (listing.image_url) { + return listing.image_url; + } + + // Prioridade 2: image_local_path se disponível + if (listing.image_local_path) { + const normalizedPath = listing.image_local_path.replace(/\\/g, "/"); + if (normalizedPath.startsWith("data/")) { + return `/api/images?path=${encodeURIComponent(normalizedPath)}`; + } + // Construir caminho se necessário + const category = listing.item_category || "other"; + return `/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`; + } + + // Prioridade 3: Usar item_id_unified se disponível (com nome para validação) + 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); + } + return `/api/images?${params.toString()}`; + } + + // Fallback: Usar item_id legado (com nome para validação) + const params = new URLSearchParams({ item: listing.item_id.toString() }); + if (listing.item_name) { + params.set("name", listing.item_name); + } + return `/api/images?${params.toString()}`; + }; + + const itemImageUrl = getItemImageUrl(); + + // Componente para exibir badges de preços + const PriceBadges = ({ prices, compact = false }: { prices: PriceInfo[]; compact?: boolean }) => { + if (prices.length === 0) { + return
Preço não informado
; + } + + const getBadgeClassName = (type: PriceInfo["type"]) => { + switch (type) { + case "pix": + return "bg-green-500 hover:bg-green-600 text-white"; + case "tc": + return "bg-blue-500 hover:bg-blue-600 text-white"; + case "gp": + return "bg-yellow-500 hover:bg-yellow-600 text-white"; + case "trade": + return "bg-purple-500 hover:bg-purple-600 text-white"; + default: + return ""; + } + }; + + const getIcon = (type: PriceInfo["type"]) => { + switch (type) { + case "pix": + return ; + case "tc": + case "gp": + return ; + case "trade": + return ; + default: + return null; + } + }; + + if (prices.length === 1 && !compact) { + // Se houver apenas um preço, mostrar em destaque + const price = prices[0]; + return ( +
+
{price.value}
+ + {getIcon(price.type)} + {price.label} + +
+ ); + } + + // Múltiplos preços: mostrar badges + if (compact) { + // Layout compacto para modo list + return ( +
+ {prices.map((price, index) => ( + + {getIcon(price.type)} + {price.value} + + ))} +
+ ); + } + + // Layout para modo grid + if (prices.length <= 3) { + // Grid 2 colunas para 2-3 preços + return ( +
+ {prices.map((price, index) => ( + + {getIcon(price.type)} + {price.value} + + ))} +
+ ); + } + + // Flex wrap para 4+ preços + return ( +
+ {prices.map((price, index) => ( + + {getIcon(price.type)} + {price.value} + + ))} +
+ ); + }; + + if (viewMode === "list") { + return ( + + {isSold && ( +
+ )} +
+ {/* Thumbnail */} +
+ {listing.item_name} { + (e.target as HTMLImageElement).src = "/placeholder-item.png"; + }} + /> +
+ + {/* Conteúdo */} +
+
+
+

{listing.item_name}

+
+ {listing.seller_character_name && ( + + )} +
+
+
+ + {timeAgo} +
+
+
+
+ {isSold && ( + + Vendido + + )} + {listing.is_featured && ( + + + Destaque + + )} + {listing.is_auction && ( + Leilão + )} + +
+
+ {listing.description && ( +

+ {listing.description} +

+ )} +
+
+ + + {/* Informações de lance se for leilão */} + {listing.is_auction && ( +
+ {listing.current_bid_amount ? ( + <> +
+ Lance Atual: + + R$ {typeof listing.current_bid_amount === 'number' ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)} + +
+
+ Leilão Ativo + + + Lances + +
+ + ) : ( +
+ Sem lances ainda +
+ )} +
+ )} +
+
+
+
+ + {listing.views_count} +
+
+ Qtd: {listing.quantity} +
+
+ + {/* Botões de ação - desabilitados se vendido */} + {user && user.userId !== listing.seller_id && listing.listing_status === "active" && !isSold && ( +
+ {listing.is_auction ? ( + + ) : ( + + )} +
+ )} + {isSold && ( +
+ Este item já foi vendido +
+ )} +
+
+
+
+ + {/* Modais */} + { + invalidateCache(); + }} + /> + { + invalidateCache(); + }} + /> + + + ); + } + + // View mode grid (padrão) + return ( + + {isSold && ( +
+ )} + + {/* Thumbnail */} +
+ {listing.item_name} { + (e.target as HTMLImageElement).src = "/placeholder-item.png"; + }} + /> + {isSold && ( +
+ + Vendido + +
+ )} + {listing.is_featured && ( +
+ + + Destaque + +
+ )} + {listing.is_auction && !isSold && ( +
+ Leilão +
+ )} +
+
+ + +

{listing.item_name}

+ +
+ {listing.seller_character_name && ( + + )} +
+ + {listing.description && ( +

+ {listing.description} +

+ )} + +
+ + + {/* Informações de lance se for leilão */} + {listing.is_auction && ( +
+ {listing.current_bid_amount ? ( + <> +
+ Lance Atual: + + R$ {typeof listing.current_bid_amount === 'number' ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)} + +
+ {listing.min_bid_increment && ( +
+ Incremento Mín: + + R$ {typeof listing.min_bid_increment === 'number' ? listing.min_bid_increment.toFixed(2) : parseFloat(String(listing.min_bid_increment)).toFixed(2)} + +
+ )} +
+ + + Leilão Ativo + +
+ + ) : ( +
+ Sem lances ainda +
+ )} +
+ )} + +
+
+ + {timeAgo} +
+
+ + {listing.views_count} +
+
+
+
+ + + {/* Botão de compra/lance - apenas se não for o vendedor, estiver ativo e não vendido */} + {user && user.userId !== listing.seller_id && listing.listing_status === "active" && !isSold && ( + <> + {listing.is_auction ? ( + + ) : ( + + )} + + )} + {isSold && ( +
+ Este item já foi vendido +
+ )} + + {/* Se não for o vendedor, mostrar botão de detalhes também */} + {(!user || user.userId === listing.seller_id) && ( + + )} + + +
+ + {/* Modais */} + { + // Recarregar página ou atualizar lista + window.location.reload(); + }} + /> + { + // Recarregar página ou atualizar lista + window.location.reload(); + }} + /> + + + ); +} + diff --git a/components/marketplace/AnalyticsDashboard.tsx b/components/marketplace/AnalyticsDashboard.tsx new file mode 100644 index 0000000..7cefbe2 --- /dev/null +++ b/components/marketplace/AnalyticsDashboard.tsx @@ -0,0 +1,269 @@ +"use client"; + +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"; +import { TrendingUp, Eye, MousePointerClick, Heart, MessageSquare, Clock } from "lucide-react"; + +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([]); + const [selectedAd, setSelectedAd] = useState(null); + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (user?.userId) { + loadUserListings(); + } + }, [user?.userId]); + + useEffect(() => { + if (selectedAd) { + loadAnalytics(selectedAd); + } + }, [selectedAd]); + + 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); + } + }; + + if (!user) { + return ( +
+

+ Faça login para ver suas analytics +

+
+ ); + } + + if (loading) { + return ( +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (listings.length === 0) { + return ( +
+

+ Você ainda não tem anúncios. Crie um anúncio para ver as analytics. +

+
+ ); + } + + return ( +
+
+

Analytics dos Anúncios

+

+ Acompanhe o desempenho dos seus anúncios +

+
+ + {/* Seletor de anúncio */} +
+ + +
+ + {/* Métricas */} + {analytics && ( +
+ + + + + Impressões + + + +
{analytics.impressions}
+

+ Visualizações do anúncio +

+
+
+ + + + + + Cliques + + + +
{analytics.clicks}
+

+ CTR: {analytics.ctr.toFixed(2)}% +

+
+
+ + + + + + Duração Média + + + +
+ {analytics.avg_view_duration.toFixed(1)}s +
+

+ Tempo médio de visualização +

+
+
+ + + + + + Bounce Rate + + + +
+ {analytics.bounce_rate.toFixed(1)}% +
+

+ Usuários que saíram em menos de 3s +

+
+
+ + + + + + Inquiries + + + +
{analytics.inquiries}
+

+ Mensagens recebidas +

+
+
+ + + + + + Favoritos + + + +
{analytics.favorites}
+

+ Usuários que favoritaram +

+
+
+ + {analytics.conversion_rate > 0 && ( + + + Taxa de Conversão + + +
+ {analytics.conversion_rate.toFixed(2)}% +
+

+ Taxa de vendas +

+
+
+ )} + + {analytics.time_to_sell && ( + + + Tempo até Venda + + +
+ {Math.round(analytics.time_to_sell)}h +
+

+ Tempo médio até ser vendido +

+
+
+ )} +
+ )} +
+ ); +} + diff --git a/components/marketplace/BidModal.tsx b/components/marketplace/BidModal.tsx new file mode 100644 index 0000000..f8229c7 --- /dev/null +++ b/components/marketplace/BidModal.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Coins, DollarSign, Loader2, TrendingUp } from "lucide-react"; +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(""); + const [paymentMethod, setPaymentMethod] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [bidHistory, setBidHistory] = useState>([]); + 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]); + } + }, [availableMethods.length]); + + // Atualizar valor do lance quando mudar + useEffect(() => { + if (bidAmount === "" && minBid > 0) { + setBidAmount(minBid.toFixed(2)); + } + }, [minBid]); + + // Buscar histórico de lances quando abrir modal + useEffect(() => { + if (open && listing) { + fetchBidHistory(); + } + }, [open, listing?.listing_id]); + + 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); + } + }; + + const handleBid = async () => { + const amount = parseFloat(bidAmount); + + if (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 ( + + + + Dar Lance + + Informe o valor do seu lance para este leilão + + + +
+ {/* Resumo do item */} +
+

{listing.item_name}

+

+ Quantidade: {listing.quantity} +

+
+ + + + {/* Informações do leilão */} +
+
+
+ +

{formatPrice(initialPrice)}

+
+
+ +

+ {currentBid > 0 ? formatPrice(currentBid) : "Sem lances"} +

+
+
+ +
+
+ +

{formatPrice(minIncrement)}

+
+
+ +

{formatPrice(minBid)}

+
+
+ + {listing.auction_ends_at && ( +
+ +

+ {new Date(listing.auction_ends_at).toLocaleString("pt-BR")} +

+
+ )} +
+ + + + {/* Histórico de lances */} + {bidHistory.length > 0 && ( +
+ +
+ {bidHistory.map((bid, index) => { + const isCurrentUser = user?.userId === bid.bidder_id; + const isHighest = index === 0; + return ( +
+
+
+
+ + {isCurrentUser ? "Você" : bid.bidder_name} + + {isHighest && ( + + Líder + + )} + {isCurrentUser && ( + + Seu lance + + )} +
+

+ {new Date(bid.created_at).toLocaleString("pt-BR")} +

+
+ + R$ {bid.bid_amount.toFixed(2)} + +
+
+ ); + })} +
+
+ )} + + {loadingHistory && ( +
+ Carregando histórico... +
+ )} + + + + {/* Input do lance */} +
+ + setBidAmount(e.target.value)} + placeholder={minBid.toFixed(2)} + className="text-lg" + /> +

+ Valor mínimo: {formatPrice(minBid)} +

+
+ + {/* Seleção de método de pagamento */} + {availableMethods.length > 0 && ( +
+ + +
+ )} + + {error && ( +
+ {error} +
+ )} + + + + {/* Botões */} +
+ + +
+
+
+
+ ); +} + diff --git a/components/marketplace/CreateAdModal.tsx b/components/marketplace/CreateAdModal.tsx new file mode 100644 index 0000000..2fab159 --- /dev/null +++ b/components/marketplace/CreateAdModal.tsx @@ -0,0 +1,450 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useSession } from "@/lib/hooks/use-session"; +import { ItemSearch } from "./ItemSearch"; +import { ArrowRight, Package } from "lucide-react"; + +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(null); + const [servers, setServers] = useState([]); + const [loadingServers, setLoadingServers] = useState(false); + + const [selectedItem, setSelectedItem] = useState(null); + const [tradeItem, setTradeItem] = useState(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) { + 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 + useEffect(() => { + if (open) { + loadServers(); + } + }, [open]); + + 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); + } + }; + + 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 ( + + + + Criar Novo Anúncio + + Preencha os dados do item que deseja vender + + + +
+ {/* Seleção do item a vender */} +
+ + +
+ +
+
+ + + setFormData({ ...formData, quantity: e.target.value }) + } + /> +
+ +
+ + +
+
+ + + + {/* Preços */} +
+ +
+
+ + + setFormData({ ...formData, price_pix: e.target.value }) + } + placeholder="0.00" + /> +
+ +
+ + + setFormData({ + ...formData, + price_tibia_coins: e.target.value, + }) + } + placeholder="0" + /> +
+ +
+ + + setFormData({ + ...formData, + price_gold_coins: e.target.value, + }) + } + placeholder="0" + /> +
+
+
+ + + + {/* Troca por item */} +
+
+ { + setAcceptTrade(checked as boolean); + if (!checked) { + setTradeItem(null); + } + }} + /> + +
+ + {acceptTrade && ( +
+
+ + +
+ + {tradeItem && ( +
+ + + setFormData({ + ...formData, + trade_item_quantity: e.target.value, + }) + } + /> +
+ )} +
+ )} +
+ + + + {/* Descrição */} +
+ +