feat: implement market page with QueryProvider and dynamic listings

- wrap root layout with QueryProvider for React Query
- replace market UnderConstruction placeholder with MarketClient and real data fetching
- implement search, pagination, and sort via search params using getActiveListings
- add necessary client components and queries
- update dependencies (e.g., prisma, pkg-types)
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-05 01:58:03 -03:00
1 parent 55eebde0ba
commit ab37b477df
106 files changed
+15261 -301

No files matched your search

+8 -5
View File
@@ -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({
<body
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
>
<NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark">
{children}
</ThemeProvider>
</NextIntlClientProvider>
<QueryProvider>
<NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark">
{children}
</ThemeProvider>
</NextIntlClientProvider>
</QueryProvider>
</body>
</html>
);
+406
View File
@@ -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<number>(initial.page || 1);
const [pageSize] = useState<number>(initial.pageSize || 20);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
// Filtros
const [searchQuery, setSearchQuery] = useState<string>("");
const [serverName, setServerName] = useState<string>("");
const [sortBy, setSortBy] = useState<string>("created_at");
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
const [priceMin, setPriceMin] = useState<string>("");
const [priceMax, setPriceMax] = useState<string>("");
const [showCreateModal, setShowCreateModal] = useState(false);
const [activeTab, setActiveTab] = useState<string>("explore");
// Usar React Query para buscar listings com atualização automática
const { listings, total, totalPages, isLoading: loading } = useListings({
search: searchQuery || undefined,
server_name: serverName || undefined,
price_min: priceMin || undefined,
price_max: priceMax || undefined,
sortBy,
sortOrder,
page,
pageSize,
});
// Tracking de página
useEffect(() => {
trackPageView("marketplace");
}, [trackPageView]);
const handleSearch = (query: string) => {
setPage(1);
setSearchQuery(query);
if (query) {
trackSearch(query, { server: serverName });
}
};
const handleSort = (newSortBy: string) => {
setPage(1);
if (sortBy === newSortBy) {
// Alternar ordem se já está ordenando por esse campo
setSortOrder(sortOrder === "ASC" ? "DESC" : "ASC");
} else {
setSortBy(newSortBy);
setSortOrder("DESC");
}
};
const handleFilter = (filters: Record<string, any>) => {
setPage(1);
if (filters.server) setServerName(filters.server);
if (filters.priceMin !== undefined) setPriceMin(String(filters.priceMin));
if (filters.priceMax !== undefined) setPriceMax(String(filters.priceMax));
trackFilter(filters);
};
const clearFilters = () => {
setSearchQuery("");
setServerName("");
setPriceMin("");
setPriceMax("");
setPage(1);
};
const sortOptions = [
{ value: "created_at", label: "Mais recentes", icon: SortDesc },
{ value: "price_pix", label: "Menor preço (PIX)", icon: SortAsc },
{ value: "views_count", label: "Mais populares", icon: SortDesc },
];
const activeFiltersCount = [
searchQuery,
serverName,
priceMin,
priceMax,
].filter(Boolean).length;
return (
<div className="space-y-6">
{/* Header com botão de criar anúncio */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Marketplace</h1>
<p className="text-muted-foreground mt-1">
{activeTab === "explore"
? `${total} ${total === 1 ? "anúncio" : "anúncios"} disponíveis`
: "Gerencie seus anúncios"}
</p>
</div>
{user && (
<Button onClick={() => setShowCreateModal(true)}>
Criar Anúncio
</Button>
)}
</div>
{/* Tabs para Explorar e Meus Anúncios */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="explore">Explorar</TabsTrigger>
{user && <TabsTrigger value="my-ads">Meus Anúncios</TabsTrigger>}
</TabsList>
{/* Aba: Explorar Anúncios */}
<TabsContent value="explore" className="space-y-6 mt-6">
{/* Barra de busca */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar anúncios por item ou descrição..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10 pr-4"
/>
</div>
{/* Filtros e controles */}
<div className="flex flex-wrap items-center gap-4">
{/* Filtro por servidor */}
<Input
placeholder="Filtrar por servidor"
value={serverName}
onChange={(e) => {
setServerName(e.target.value);
handleFilter({ server: e.target.value });
}}
className="w-48"
/>
{/* Filtro por preço mínimo */}
<Input
type="number"
placeholder="Preço min"
value={priceMin}
onChange={(e) => {
setPriceMin(e.target.value);
handleFilter({ priceMin: e.target.value ? parseFloat(e.target.value) : undefined });
}}
className="w-32"
/>
{/* Filtro por preço máximo */}
<Input
type="number"
placeholder="Preço max"
value={priceMax}
onChange={(e) => {
setPriceMax(e.target.value);
handleFilter({ priceMax: e.target.value ? parseFloat(e.target.value) : undefined });
}}
className="w-32"
/>
{/* Ordenação */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
{(() => {
const selectedOption = sortOptions.find((opt) => opt.value === sortBy);
const Icon = selectedOption?.icon;
return Icon ? <Icon className="h-4 w-4" /> : null;
})()}
{sortOptions.find((opt) => opt.value === sortBy)?.label || "Ordenar"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Ordenar por</DropdownMenuLabel>
<DropdownMenuSeparator />
{sortOptions.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuItem
key={option.value}
onClick={() => handleSort(option.value)}
className="flex items-center gap-2"
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
{sortBy === option.value && (
<div className="w-2 h-2 bg-primary rounded-full ml-auto" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Toggle de visualização */}
<div className="flex items-center gap-1 border rounded-md p-1">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
>
<List className="h-4 w-4" />
</Button>
</div>
{/* Limpar filtros */}
{activeFiltersCount > 0 && (
<Button variant="ghost" onClick={clearFilters} className="gap-2">
<X className="h-4 w-4" />
Limpar Filtros
</Button>
)}
</div>
{/* Badges de filtros ativos */}
{activeFiltersCount > 0 && (
<div className="flex flex-wrap gap-2">
{searchQuery && (
<Badge variant="secondary" className="gap-1">
Busca: {searchQuery}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setSearchQuery("");
setPage(1);
}}
/>
</Badge>
)}
{serverName && (
<Badge variant="secondary" className="gap-1">
Servidor: {serverName}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setServerName("");
setPage(1);
}}
/>
</Badge>
)}
{(priceMin || priceMax) && (
<Badge variant="secondary" className="gap-1">
Preço: {priceMin || "0"} - {priceMax || "∞"}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setPriceMin("");
setPriceMax("");
setPage(1);
}}
/>
</Badge>
)}
</div>
)}
{/* Listagem de anúncios */}
{loading ? (
<div
className={
viewMode === "grid"
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-4"
}
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full" />
))}
</div>
) : listings.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
Nenhum anúncio encontrado
</p>
<p className="text-muted-foreground text-sm mt-2">
Tente ajustar os filtros ou criar um novo anúncio
</p>
</div>
) : (
<>
<div
className={
viewMode === "grid"
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-4"
}
>
{listings.map((listing) => (
<AdCard key={listing.listing_id} listing={listing} viewMode={viewMode} />
))}
</div>
{/* Paginação */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Anterior
</Button>
<span className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</span>
<Button
variant="outline"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Próxima
</Button>
</div>
)}
</>
)}
{/* Modal de criação de anúncio */}
{showCreateModal && (
<CreateAdModal
open={showCreateModal}
onOpenChange={setShowCreateModal}
onSuccess={() => {
setShowCreateModal(false);
// Invalidar cache para recarregar lista automaticamente
invalidateListings();
// Se estiver na aba explore, mudar para meus anúncios para ver o novo
if (activeTab === "explore") {
setActiveTab("my-ads");
}
}}
/>
)}
</TabsContent>
{/* Aba: Meus Anúncios */}
{user && (
<TabsContent value="my-ads" className="mt-6">
<MyAdsPanel />
</TabsContent>
)}
</Tabs>
</div>
);
}
+41
View File
@@ -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<Metadata> {
const { locale } = await params;
return {
title: "Analytics - Marketplace",
description: "Acompanhe o desempenho dos seus anúncios",
};
}
export default async function AnalyticsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Verificar autenticação (server-side)
// Nota: Em produção, usar headers() do Next.js para obter request
// Por enquanto, deixar o componente fazer a verificação client-side
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8">
<AnalyticsDashboard />
</main>
</div>
);
}
+33 -10
View File
@@ -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<Metadata> {
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<Record<string, string | string[] | undefined>>;
}) {
const { locale } = await params;
const t = await getTranslations({
locale,
namespace: "underConstruction.market",
const sp = (await searchParams) ?? {};
const page = typeof sp.page === "string" ? parseInt(sp.page, 10) : 1;
const pageSize = typeof sp.pageSize === "string" ? parseInt(sp.pageSize, 10) : 20;
// Buscar anúncios ativos
const listingsData = await getActiveListings({
search: typeof sp.search === "string" ? sp.search : undefined,
server_name: typeof sp.server_name === "string" ? sp.server_name : undefined,
page,
pageSize,
sortBy: (typeof sp.sortBy === "string" ? sp.sortBy : "created_at") as
| "created_at"
| "price_pix"
| "price_tibia_coins"
| "price_gold_coins"
| "views_count",
sortOrder: (typeof sp.sortOrder === "string" ? sp.sortOrder : "DESC") as "ASC" | "DESC",
});
return (
<div className="min-h-screen">
<Header />
<UnderConstruction
title={t("title")}
description={t("description")}
locale={locale}
/>
<main className="container mx-auto px-4 py-8">
<MarketClient
initial={{
listings: listingsData.listings,
total: listingsData.total,
page,
pageSize,
totalPages: Math.ceil(listingsData.total / pageSize),
}}
/>
</main>
</div>
);
}
+1 -1
View File
@@ -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";
+2 -2
View File
@@ -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 {
+67 -74
View File
@@ -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
+95 -2
View File
@@ -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 });
}
}
+112
View File
@@ -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 }
);
}
}
+118
View File
@@ -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 }
);
}
}
+108
View File
@@ -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 }
);
}
}
+137
View File
@@ -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 }
);
}
}
@@ -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 }
);
}
}
+242
View File
@@ -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 }
);
}
}
+139
View File
@@ -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 }
);
}
}
+113
View File
@@ -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 }
);
}
}
+166
View File
@@ -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 }
);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import {
getListingById,
updateListing,
deleteListing,
} from "@/lib/db/queries/listings";
} from "@/lib/prisma/queries/listings";
import { updateListingSchema } from "@/lib/validation-schemas";
/**
+57
View File
@@ -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 }
);
}
}
+1 -1
View File
@@ -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
+78
View File
@@ -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 }
);
}
}
+6 -2
View File
@@ -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", {
+54
View File
@@ -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 }
);
}
}
+95
View File
@@ -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 }
);
}
}
@@ -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 }
);
}
}
+1 -1
View File
@@ -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";
/**
+240
View File
@@ -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 }
);
}
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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) {
+4
View File
@@ -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() {
<ThemeToggle />
<LanguageSwitcher />
{/* Notifications */}
{mounted && isAuthenticated && <NotificationBell />}
{/* Auth Section */}
{!mounted ? (
// Placeholder durante SSR para evitar mismatch
+17
View File
@@ -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 (
<Button variant="outline" size="icon" disabled>
<Globe className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">{t("language")}</span>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
+799
View File
@@ -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 <div className="text-sm text-muted-foreground">Preço não informado</div>;
}
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 <DollarSign className="h-3 w-3" />;
case "tc":
case "gp":
return <Coins className="h-3 w-3" />;
case "trade":
return <RefreshCw className="h-3 w-3" />;
default:
return null;
}
};
if (prices.length === 1 && !compact) {
// Se houver apenas um preço, mostrar em destaque
const price = prices[0];
return (
<div className="flex items-center gap-2">
<div className="text-2xl font-bold">{price.value}</div>
<Badge className={getBadgeClassName(price.type)}>
{getIcon(price.type)}
<span className="ml-1">{price.label}</span>
</Badge>
</div>
);
}
// Múltiplos preços: mostrar badges
if (compact) {
// Layout compacto para modo list
return (
<div className="flex flex-wrap items-center gap-2">
{prices.map((price, index) => (
<Badge key={index} className={getBadgeClassName(price.type)}>
{getIcon(price.type)}
<span className="ml-1">{price.value}</span>
</Badge>
))}
</div>
);
}
// Layout para modo grid
if (prices.length <= 3) {
// Grid 2 colunas para 2-3 preços
return (
<div className="grid grid-cols-2 gap-2">
{prices.map((price, index) => (
<Badge key={index} className={`${getBadgeClassName(price.type)} justify-center`}>
{getIcon(price.type)}
<span className="ml-1 text-xs">{price.value}</span>
</Badge>
))}
</div>
);
}
// Flex wrap para 4+ preços
return (
<div className="flex flex-wrap gap-2">
{prices.map((price, index) => (
<Badge key={index} className={`${getBadgeClassName(price.type)} text-xs`}>
{getIcon(price.type)}
<span className="ml-1">{price.value}</span>
</Badge>
))}
</div>
);
};
if (viewMode === "list") {
return (
<Card
className={`hover:shadow-lg transition-shadow ${isSold ? "opacity-75 relative" : ""}`}
>
{isSold && (
<div className="absolute inset-0 bg-black/10 rounded-lg z-10 pointer-events-none" />
)}
<div className="flex gap-4 p-4">
{/* Thumbnail */}
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-muted flex items-center justify-center p-2">
<Image
src={itemImageUrl}
alt={listing.item_name}
fill
className="object-contain"
sizes="96px"
onError={(e) => {
(e.target as HTMLImageElement).src = "/placeholder-item.png";
}}
/>
</div>
{/* Conteúdo */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3>
<div className="mt-2 mb-2">
{listing.seller_character_name && (
<SellerInfo
sellerId={listing.seller_id}
sellerName={listing.seller_character_name}
serverName={listing.server_name}
listingId={listing.listing_id}
/>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{timeAgo}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isSold && (
<Badge variant="default" className="bg-red-600 text-white font-semibold">
Vendido
</Badge>
)}
{listing.is_featured && (
<Badge variant="default" className="bg-yellow-500">
<Star className="h-3 w-3 mr-1" />
Destaque
</Badge>
)}
{listing.is_auction && (
<Badge variant="secondary">Leilão</Badge>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFavorite();
}}
disabled={!user}
>
<Heart
className={`h-4 w-4 ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
/>
<span className="ml-1">{favoritesCount}</span>
</Button>
</div>
</div>
{listing.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{listing.description}
</p>
)}
<div className="flex items-center justify-between mt-3">
<div className="flex-1 min-w-0">
<PriceBadges prices={availablePrices} compact />
{/* Informações de lance se for leilão */}
{listing.is_auction && (
<div className="mt-2 bg-muted p-2 rounded text-xs space-y-1">
{listing.current_bid_amount ? (
<>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Lance Atual:</span>
<span className="font-semibold text-primary">
R$ {typeof listing.current_bid_amount === 'number' ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Leilão Ativo</span>
<Badge variant="secondary" className="text-xs">
<TrendingUp className="h-3 w-3 mr-1" />
Lances
</Badge>
</div>
</>
) : (
<div className="text-center text-muted-foreground">
Sem lances ainda
</div>
)}
</div>
)}
</div>
<div className="flex items-center gap-4 ml-4">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Eye className="h-4 w-4" />
<span>{listing.views_count}</span>
</div>
<div className="flex items-center gap-1">
<span>Qtd: {listing.quantity}</span>
</div>
</div>
{/* Botões de ação - desabilitados se vendido */}
{user && user.userId !== listing.seller_id && listing.listing_status === "active" && !isSold && (
<div className="flex items-center gap-2">
{listing.is_auction ? (
<Button
variant="default"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowBidModal(true);
}}
disabled={isSold}
>
<TrendingUp className="h-4 w-4 mr-2" />
Dar Lance
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowPurchaseModal(true);
}}
disabled={isSold}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Comprar
</Button>
)}
</div>
)}
{isSold && (
<div className="text-sm text-muted-foreground italic">
Este item já foi vendido
</div>
)}
</div>
</div>
</div>
</div>
{/* Modais */}
<PurchaseModal
open={showPurchaseModal}
onOpenChange={setShowPurchaseModal}
listing={listing}
onSuccess={() => {
invalidateCache();
}}
/>
<BidModal
open={showBidModal}
onOpenChange={setShowBidModal}
listing={listing}
onSuccess={() => {
invalidateCache();
}}
/>
<ListingDetailsModal
open={showDetailsModal}
onOpenChange={setShowDetailsModal}
listing={listing}
onPause={handlePause}
onActivate={handleActivate}
onCancel={handleCancel}
/>
</Card>
);
}
// View mode grid (padrão)
return (
<Card
className={`hover:shadow-lg transition-shadow flex flex-col ${isSold ? "opacity-75 relative" : ""}`}
>
{isSold && (
<div className="absolute inset-0 bg-black/10 rounded-lg z-10 pointer-events-none" />
)}
<CardHeader className="p-0 relative">
{/* Thumbnail */}
<div className="relative w-full aspect-square rounded-t-lg overflow-hidden bg-muted flex items-center justify-center p-4">
<Image
src={itemImageUrl}
alt={listing.item_name}
fill
className="object-contain"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
onError={(e) => {
(e.target as HTMLImageElement).src = "/placeholder-item.png";
}}
/>
{isSold && (
<div className="absolute top-2 right-2 z-20">
<Badge variant="default" className="bg-red-600 text-white font-semibold">
Vendido
</Badge>
</div>
)}
{listing.is_featured && (
<div className={`absolute ${isSold ? "top-2 left-2" : "top-2 right-2"} z-20`}>
<Badge variant="default" className="bg-yellow-500">
<Star className="h-3 w-3 mr-1" />
Destaque
</Badge>
</div>
)}
{listing.is_auction && !isSold && (
<div className="absolute top-2 left-2 z-20">
<Badge variant="secondary">Leilão</Badge>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-4 flex-1 flex flex-col">
<h3 className="font-semibold text-lg mb-2 line-clamp-2">{listing.item_name}</h3>
<div className="mb-2">
{listing.seller_character_name && (
<SellerInfo
sellerId={listing.seller_id}
sellerName={listing.seller_character_name}
serverName={listing.server_name}
listingId={listing.listing_id}
/>
)}
</div>
{listing.description && (
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{listing.description}
</p>
)}
<div className="mt-auto space-y-2">
<PriceBadges prices={availablePrices} />
{/* Informações de lance se for leilão */}
{listing.is_auction && (
<div className="bg-muted p-2 rounded text-xs space-y-1">
{listing.current_bid_amount ? (
<>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Lance Atual:</span>
<span className="font-semibold text-primary">
R$ {typeof listing.current_bid_amount === 'number' ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)}
</span>
</div>
{listing.min_bid_increment && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Incremento Mín:</span>
<span className="font-medium">
R$ {typeof listing.min_bid_increment === 'number' ? listing.min_bid_increment.toFixed(2) : parseFloat(String(listing.min_bid_increment)).toFixed(2)}
</span>
</div>
)}
<div className="flex items-center justify-center pt-1">
<Badge variant="secondary" className="text-xs">
<TrendingUp className="h-3 w-3 mr-1" />
Leilão Ativo
</Badge>
</div>
</>
) : (
<div className="text-center text-muted-foreground py-1">
Sem lances ainda
</div>
)}
</div>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{timeAgo}</span>
</div>
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" />
<span>{listing.views_count}</span>
</div>
</div>
</div>
</CardContent>
<CardFooter className="p-4 pt-0 flex items-center justify-between gap-2">
{/* 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 ? (
<Button
variant="default"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
setShowBidModal(true);
}}
disabled={isSold}
>
<TrendingUp className="h-4 w-4 mr-2" />
Dar Lance
</Button>
) : (
<Button
variant="default"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
setShowPurchaseModal(true);
}}
disabled={isSold}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Comprar
</Button>
)}
</>
)}
{isSold && (
<div className="text-sm text-muted-foreground italic flex-1 text-center">
Este item já foi vendido
</div>
)}
{/* Se não for o vendedor, mostrar botão de detalhes também */}
{(!user || user.userId === listing.seller_id) && (
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleClick}
>
Ver Detalhes
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFavorite();
}}
disabled={!user}
>
<Heart
className={`h-4 w-4 ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
/>
<span className="ml-1">{favoritesCount}</span>
</Button>
</CardFooter>
{/* Modais */}
<PurchaseModal
open={showPurchaseModal}
onOpenChange={setShowPurchaseModal}
listing={listing}
onSuccess={() => {
// Recarregar página ou atualizar lista
window.location.reload();
}}
/>
<BidModal
open={showBidModal}
onOpenChange={setShowBidModal}
listing={listing}
onSuccess={() => {
// Recarregar página ou atualizar lista
window.location.reload();
}}
/>
<ListingDetailsModal
open={showDetailsModal}
onOpenChange={setShowDetailsModal}
listing={listing}
onPause={handlePause}
onActivate={handleActivate}
onCancel={handleCancel}
/>
</Card>
);
}
@@ -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<Listing[]>([]);
const [selectedAd, setSelectedAd] = useState<number | null>(null);
const [analytics, setAnalytics] = useState<AdAnalytics | null>(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 (
<div className="text-center py-12">
<p className="text-muted-foreground">
Faça login para ver suas analytics
</p>
</div>
);
}
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
</div>
);
}
if (listings.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
Você ainda não tem anúncios. Crie um anúncio para ver as analytics.
</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Analytics dos Anúncios</h1>
<p className="text-muted-foreground mt-1">
Acompanhe o desempenho dos seus anúncios
</p>
</div>
{/* Seletor de anúncio */}
<div className="space-y-2">
<label className="text-sm font-medium">Selecione um anúncio</label>
<select
className="w-full p-2 border rounded-md"
value={selectedAd || ""}
onChange={(e) => setSelectedAd(parseInt(e.target.value, 10))}
>
{listings.map((listing) => (
<option key={listing.listing_id} value={listing.listing_id}>
{listing.item_name} ({listing.listing_status})
</option>
))}
</select>
</div>
{/* Métricas */}
{analytics && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Impressões
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.impressions}</div>
<p className="text-sm text-muted-foreground mt-1">
Visualizações do anúncio
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MousePointerClick className="h-5 w-5" />
Cliques
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.clicks}</div>
<p className="text-sm text-muted-foreground mt-1">
CTR: {analytics.ctr.toFixed(2)}%
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Duração Média
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{analytics.avg_view_duration.toFixed(1)}s
</div>
<p className="text-sm text-muted-foreground mt-1">
Tempo médio de visualização
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Bounce Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{analytics.bounce_rate.toFixed(1)}%
</div>
<p className="text-sm text-muted-foreground mt-1">
Usuários que saíram em menos de 3s
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Inquiries
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.inquiries}</div>
<p className="text-sm text-muted-foreground mt-1">
Mensagens recebidas
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Heart className="h-5 w-5" />
Favoritos
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.favorites}</div>
<p className="text-sm text-muted-foreground mt-1">
Usuários que favoritaram
</p>
</CardContent>
</Card>
{analytics.conversion_rate > 0 && (
<Card>
<CardHeader>
<CardTitle>Taxa de Conversão</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{analytics.conversion_rate.toFixed(2)}%
</div>
<p className="text-sm text-muted-foreground mt-1">
Taxa de vendas
</p>
</CardContent>
</Card>
)}
{analytics.time_to_sell && (
<Card>
<CardHeader>
<CardTitle>Tempo até Venda</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{Math.round(analytics.time_to_sell)}h
</div>
<p className="text-sm text-muted-foreground mt-1">
Tempo médio até ser vendido
</p>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}
+366
View File
@@ -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<string>("");
const [paymentMethod, setPaymentMethod] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bidHistory, setBidHistory] = useState<Array<{
bid_id: number;
bidder_id: number;
bidder_name: string;
bid_amount: number;
bid_type: string | null;
created_at: Date;
}>>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
if (!listing) return null;
// Calcular valores
const currentBid = listing.current_bid_amount || 0;
const minIncrement = listing.min_bid_increment || 10;
const minBid = currentBid + minIncrement;
const initialPrice = listing.initial_price_pix || listing.initial_price_tibia_coins || listing.initial_price_gold_coins || 0;
// Determinar métodos de pagamento disponíveis para lance
const availableMethods: string[] = [];
if (listing.price_pix || listing.initial_price_pix) availableMethods.push("pix");
if (listing.price_tibia_coins || listing.initial_price_tibia_coins) availableMethods.push("tc");
if (listing.price_gold_coins || listing.initial_price_gold_coins) availableMethods.push("gp");
// Selecionar primeiro método disponível por padrão
useEffect(() => {
if (availableMethods.length > 0 && !paymentMethod) {
setPaymentMethod(availableMethods[0]);
}
}, [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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Dar Lance</DialogTitle>
<DialogDescription>
Informe o valor do seu lance para este leilão
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Resumo do item */}
<div className="bg-muted p-4 rounded-lg">
<h3 className="font-semibold mb-2">{listing.item_name}</h3>
<p className="text-sm text-muted-foreground">
Quantidade: {listing.quantity}
</p>
</div>
<Separator />
{/* Informações do leilão */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">Preço Inicial</Label>
<p className="text-lg font-semibold">{formatPrice(initialPrice)}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Lance Atual</Label>
<p className="text-lg font-semibold text-primary">
{currentBid > 0 ? formatPrice(currentBid) : "Sem lances"}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">Incremento Mínimo</Label>
<p className="text-sm font-medium">{formatPrice(minIncrement)}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Lance Mínimo Necessário</Label>
<p className="text-sm font-semibold text-green-600">{formatPrice(minBid)}</p>
</div>
</div>
{listing.auction_ends_at && (
<div>
<Label className="text-xs text-muted-foreground">Término do Leilão</Label>
<p className="text-sm">
{new Date(listing.auction_ends_at).toLocaleString("pt-BR")}
</p>
</div>
)}
</div>
<Separator />
{/* Histórico de lances */}
{bidHistory.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Histórico de Lances (Últimos 5)</Label>
<div className="bg-muted rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
{bidHistory.map((bid, index) => {
const isCurrentUser = user?.userId === bid.bidder_id;
const isHighest = index === 0;
return (
<div
key={bid.bid_id}
className={`p-2 rounded ${
isCurrentUser ? "bg-primary/10 border border-primary/20" : ""
} ${isHighest ? "border-l-2 border-l-primary" : ""}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{isCurrentUser ? "Você" : bid.bidder_name}
</span>
{isHighest && (
<Badge variant="secondary" className="text-xs">
Líder
</Badge>
)}
{isCurrentUser && (
<Badge variant="outline" className="text-xs">
Seu lance
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{new Date(bid.created_at).toLocaleString("pt-BR")}
</p>
</div>
<span className="font-semibold text-primary">
R$ {bid.bid_amount.toFixed(2)}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{loadingHistory && (
<div className="text-center text-sm text-muted-foreground py-4">
Carregando histórico...
</div>
)}
<Separator />
{/* Input do lance */}
<div className="space-y-2">
<Label htmlFor="bid-amount">Valor do Lance (R$)</Label>
<Input
id="bid-amount"
type="number"
step="0.01"
min={minBid}
value={bidAmount}
onChange={(e) => setBidAmount(e.target.value)}
placeholder={minBid.toFixed(2)}
className="text-lg"
/>
<p className="text-xs text-muted-foreground">
Valor mínimo: {formatPrice(minBid)}
</p>
</div>
{/* Seleção de método de pagamento */}
{availableMethods.length > 0 && (
<div className="space-y-2">
<Label htmlFor="payment-method">Método de Pagamento</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger id="payment-method">
<SelectValue placeholder="Selecione o método" />
</SelectTrigger>
<SelectContent>
{availableMethods.map((method) => (
<SelectItem key={method} value={method}>
{method.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
{error}
</div>
)}
<Separator />
{/* Botões */}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancelar
</Button>
<Button
onClick={handleBid}
disabled={loading || !bidAmount || parseFloat(bidAmount) < minBid || !paymentMethod}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Processando...
</>
) : (
<>
<TrendingUp className="h-4 w-4 mr-2" />
Confirmar Lance
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+450
View File
@@ -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<string | null>(null);
const [servers, setServers] = useState<Server[]>([]);
const [loadingServers, setLoadingServers] = useState(false);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [tradeItem, setTradeItem] = useState<Item | null>(null);
const [acceptTrade, setAcceptTrade] = useState(false);
const [formData, setFormData] = useState({
quantity: "1",
server_name: "",
price_pix: "",
price_tibia_coins: "",
price_gold_coins: "",
trade_item_quantity: "1",
description: "",
});
const handleItemSelect = (item: Item | null) => {
setSelectedItem(item);
};
const handleTradeItemSelect = (item: Item | null) => {
setTradeItem(item);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
// Validar item selecionado
if (!selectedItem) {
setError("Selecione um item para vender");
setLoading(false);
return;
}
// Validar pelo menos um método de pagamento (preço ou troca)
const hasPrice =
formData.price_pix ||
formData.price_tibia_coins ||
formData.price_gold_coins;
if (!hasPrice && (!acceptTrade || !tradeItem)) {
setError(
"Informe pelo menos um preço (PIX, TC ou GP) ou configure uma troca por item"
);
setLoading(false);
return;
}
// Validar troca se aceita troca
if (acceptTrade && !tradeItem) {
setError("Selecione o item que deseja receber em troca");
setLoading(false);
return;
}
const response = await fetch("/api/listings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
item_id: selectedItem.id,
item_name: selectedItem.name,
quantity: parseInt(formData.quantity, 10),
server_name: formData.server_name,
price_pix: formData.price_pix ? parseFloat(formData.price_pix) : null,
price_tibia_coins: formData.price_tibia_coins
? parseInt(formData.price_tibia_coins, 10)
: null,
price_gold_coins: formData.price_gold_coins
? parseInt(formData.price_gold_coins, 10)
: null,
description: formData.description || null,
trade_item_id: acceptTrade && tradeItem ? tradeItem.id : null,
trade_item_name: acceptTrade && tradeItem ? tradeItem.name : null,
trade_item_quantity: acceptTrade && tradeItem
? parseInt(formData.trade_item_quantity, 10)
: null,
}),
});
const data = await response.json();
if (!response.ok) {
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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Criar Novo Anúncio</DialogTitle>
<DialogDescription>
Preencha os dados do item que deseja vender
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Seleção do item a vender */}
<div className="space-y-2">
<Label htmlFor="item_search">Item a Vender *</Label>
<ItemSearch
onItemSelect={handleItemSelect}
selectedItem={selectedItem}
placeholder="Pesquise o nome do item..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="quantity">Quantidade *</Label>
<Input
id="quantity"
type="number"
min="1"
required
value={formData.quantity}
onChange={(e) =>
setFormData({ ...formData, quantity: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="server_name">Servidor *</Label>
<Select
value={formData.server_name}
onValueChange={(value) =>
setFormData({ ...formData, server_name: value })
}
required
disabled={loadingServers}
>
<SelectTrigger id="server_name">
<SelectValue placeholder="Selecione um servidor" />
</SelectTrigger>
<SelectContent>
{servers.length === 0 && !loadingServers ? (
<SelectItem value="" disabled>
Nenhum servidor disponível
</SelectItem>
) : (
servers.map((server) => (
<SelectItem key={server.serverId} value={server.serverName}>
{server.serverName}
{server.location && ` (${server.location})`}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* Preços */}
<div className="space-y-2">
<Label>Preços (opcional se aceitar troca)</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="price_pix" className="text-sm">
PIX (R$)
</Label>
<Input
id="price_pix"
type="number"
step="0.01"
min="0"
value={formData.price_pix}
onChange={(e) =>
setFormData({ ...formData, price_pix: e.target.value })
}
placeholder="0.00"
/>
</div>
<div className="space-y-2">
<Label htmlFor="price_tibia_coins" className="text-sm">
Tibia Coins
</Label>
<Input
id="price_tibia_coins"
type="number"
min="0"
value={formData.price_tibia_coins}
onChange={(e) =>
setFormData({
...formData,
price_tibia_coins: e.target.value,
})
}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="price_gold_coins" className="text-sm">
Gold Coins
</Label>
<Input
id="price_gold_coins"
type="number"
min="0"
value={formData.price_gold_coins}
onChange={(e) =>
setFormData({
...formData,
price_gold_coins: e.target.value,
})
}
placeholder="0"
/>
</div>
</div>
</div>
<Separator />
{/* Troca por item */}
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="accept_trade"
checked={acceptTrade}
onCheckedChange={(checked) => {
setAcceptTrade(checked as boolean);
if (!checked) {
setTradeItem(null);
}
}}
/>
<Label
htmlFor="accept_trade"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer flex items-center gap-2"
>
<Package className="h-4 w-4" />
Aceitar troca por item (além dos valores monetários)
</Label>
</div>
{acceptTrade && (
<div className="space-y-4 pl-6 border-l-2 border-primary/20">
<div className="space-y-2">
<Label>Item Desejado em Troca *</Label>
<ItemSearch
onItemSelect={handleTradeItemSelect}
selectedItem={tradeItem}
placeholder="Pesquise o item que deseja receber..."
/>
</div>
{tradeItem && (
<div className="space-y-2">
<Label htmlFor="trade_item_quantity">
Quantidade do Item de Troca *
</Label>
<Input
id="trade_item_quantity"
type="number"
min="1"
required
value={formData.trade_item_quantity}
onChange={(e) =>
setFormData({
...formData,
trade_item_quantity: e.target.value,
})
}
/>
</div>
)}
</div>
)}
</div>
<Separator />
{/* Descrição */}
<div className="space-y-2">
<Label htmlFor="description">Descrição (opcional)</Label>
<Textarea
id="description"
maxLength={5000}
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Descreva o item, condições, etc..."
rows={4}
/>
<p className="text-xs text-muted-foreground">
{formData.description.length}/5000 caracteres
</p>
</div>
{error && (
<div className="p-3 bg-destructive/10 text-destructive rounded-md text-sm">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading}
>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Criando..." : "Criar Anúncio"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,427 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
ShoppingCart,
Clock,
CheckCircle2,
XCircle,
MessageSquare,
User,
Loader2,
ShoppingBag,
} from "lucide-react";
import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
interface PurchaseIntention {
intention_id: number;
buyer_id: number;
payment_method: string;
status: "pending" | "accepted" | "rejected" | "expired" | "completed";
buyer_message: string | null;
seller_response: string | null;
created_at: string;
buyer?: {
user_id: number;
username: string;
email: string;
};
}
interface IntentionDetailModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
intention: PurchaseIntention;
listingId: number;
onUpdate?: () => void;
}
function getPaymentMethodLabel(method: string): string {
switch (method) {
case "pix":
return "PIX";
case "tibia_coins":
return "Tibia Coins";
case "gold_coins":
return "Gold Coins";
case "trade":
return "Troca";
default:
return method.toUpperCase();
}
}
function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
export function IntentionDetailModal({
open,
onOpenChange,
intention,
listingId,
onUpdate,
}: IntentionDetailModalProps) {
const { user } = useSession();
const queryClient = useQueryClient();
const [sellerResponse, setSellerResponse] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [listing, setListing] = useState<{ seller_id: number; listing_status: string } | null>(null);
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false);
// Buscar detalhes do listing para verificar se usuário é dono e status
useEffect(() => {
if (open && listingId) {
fetch(`/api/listings/${listingId}`)
.then((res) => res.json())
.then((data) => {
if (data.success && data.listing) {
setListing(data.listing);
}
})
.catch((err) => console.error("Erro ao buscar listing:", err));
}
}, [open, listingId]);
const isOwner = user && listing && user.userId === listing.seller_id;
const canMarkAsSold = isOwner && listing && (listing.listing_status === "active" || listing.listing_status === "in_escrow");
const canAcceptOrReject = intention.status === "pending";
const handleAccept = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/listings/${listingId}/intentions/${intention.intention_id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "accepted",
seller_response: sellerResponse || undefined,
}),
}
);
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Erro ao aceitar intenção");
}
if (onUpdate) onUpdate();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao aceitar intenção");
} finally {
setLoading(false);
}
};
const handleReject = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/listings/${listingId}/intentions/${intention.intention_id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "rejected",
seller_response: sellerResponse || undefined,
}),
}
);
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Erro ao rejeitar intenção");
}
if (onUpdate) onUpdate();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao rejeitar intenção");
} finally {
setLoading(false);
}
};
const handleMarkAsSold = async () => {
setMarkingSold(true);
try {
const res = await fetch(`/api/listings/${listingId}/mark-sold`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido");
}
// Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
if (onUpdate) onUpdate();
setShowMarkSoldDialog(false);
onOpenChange(false);
} catch (error) {
console.error("Erro ao marcar como vendido:", error);
alert(error instanceof Error ? error.message : "Erro ao marcar como vendido");
} finally {
setMarkingSold(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Intenção de Compra
{getStatusBadge(intention.status)}
</DialogTitle>
<DialogDescription>
Detalhes da intenção de compra de {intention.buyer?.username || "usuário"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Informações do comprador */}
<div className="bg-muted p-4 rounded-lg">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarFallback>
{intention.buyer?.username.charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<User className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold">
{intention.buyer?.username || "Usuário"}
</span>
</div>
<p className="text-sm text-muted-foreground">
{intention.buyer?.email || "Email não disponível"}
</p>
</div>
</div>
</div>
<Separator />
{/* Informações da intenção */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">Método de Pagamento</Label>
<div className="flex items-center gap-2 mt-1">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{getPaymentMethodLabel(intention.payment_method)}
</span>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Data de Criação</Label>
<div className="flex items-center gap-2 mt-1">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{new Date(intention.created_at).toLocaleString("pt-BR")}
</span>
</div>
</div>
</div>
{/* Mensagem do comprador */}
{intention.buyer_message && (
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Mensagem do Comprador
</Label>
<div className="bg-muted p-3 rounded-lg">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5" />
<p className="text-sm flex-1">{intention.buyer_message}</p>
</div>
</div>
</div>
)}
{/* Resposta do vendedor */}
{intention.seller_response && (
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Sua Resposta
</Label>
<div className="bg-muted p-3 rounded-lg">
<p className="text-sm">{intention.seller_response}</p>
</div>
</div>
)}
</div>
{/* Campo de resposta (apenas se pendente) */}
{canAcceptOrReject && (
<div className="space-y-2">
<Label htmlFor="seller-response">Resposta (Opcional)</Label>
<Textarea
id="seller-response"
placeholder="Deixe uma mensagem para o comprador..."
value={sellerResponse}
onChange={(e) => setSellerResponse(e.target.value)}
rows={3}
/>
</div>
)}
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
{error}
</div>
)}
<Separator />
{/* Botões de ação */}
<div className="flex gap-2 justify-end flex-wrap">
{canMarkAsSold && (
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
disabled={loading || markingSold}
className="bg-green-600 hover:bg-green-700"
>
<ShoppingBag className="h-4 w-4 mr-2" />
Marcar como Vendido
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading || markingSold}>
Fechar
</Button>
{canAcceptOrReject && (
<>
<Button
variant="destructive"
onClick={handleReject}
disabled={loading || markingSold}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Rejeitando...
</>
) : (
<>
<XCircle className="h-4 w-4 mr-2" />
Rejeitar
</>
)}
</Button>
<Button onClick={handleAccept} disabled={loading || markingSold}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Aceitando...
</>
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
Aceitar
</>
)}
</Button>
</>
)}
</div>
</div>
</DialogContent>
{/* Dialog de confirmação para marcar como vendido */}
<Dialog open={showMarkSoldDialog} onOpenChange={setShowMarkSoldDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
</DialogDescription>
</DialogHeader>
<div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
<li>O anúncio será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</li>
<li>Após 1 hora, o anúncio sairá automaticamente da lista de anúncios ativos</li>
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul>
<p className="pt-2 font-medium text-foreground">
Tem certeza que deseja marcar este anúncio como vendido?
</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<Button
variant="outline"
onClick={() => setShowMarkSoldDialog(false)}
disabled={markingSold}
>
Cancelar
</Button>
<Button
onClick={handleMarkAsSold}
disabled={markingSold}
className="bg-green-600 hover:bg-green-700"
>
{markingSold ? "Marcando..." : "Sim, marcar como vendido"}
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Search, X, Loader2 } from "lucide-react";
import Image from "next/image";
import { cn } from "@/lib/utils";
interface Item {
id: number;
name: string;
category?: string;
image?: string;
rarity?: string;
level?: number;
weight?: number;
npc_price?: number;
[key: string]: any;
}
interface ItemSearchProps {
onItemSelect: (item: Item) => void;
selectedItem?: Item | null;
placeholder?: string;
className?: string;
}
export function ItemSearch({
onItemSelect,
selectedItem,
placeholder = "Pesquisar item...",
className,
}: ItemSearchProps) {
const [searchQuery, setSearchQuery] = useState("");
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [previewItem, setPreviewItem] = useState<Item | null>(selectedItem || null);
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout>();
// Buscar itens ao digitar
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (searchQuery.trim().length < 2) {
setItems([]);
setShowDropdown(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`
);
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
setShowDropdown(true);
}
} catch (error) {
console.error("Erro ao buscar itens:", error);
} finally {
setLoading(false);
}
}, 300);
}, [searchQuery]);
// Fechar dropdown ao clicar fora
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Atualizar preview quando item selecionado mudar externamente
useEffect(() => {
if (selectedItem) {
setPreviewItem(selectedItem);
setSearchQuery(selectedItem.name);
}
}, [selectedItem]);
const handleItemClick = (item: Item) => {
setPreviewItem(item);
setSearchQuery(item.name);
setShowDropdown(false);
onItemSelect(item);
};
const handleClear = () => {
setSearchQuery("");
setPreviewItem(null);
setItems([]);
setShowDropdown(false);
onItemSelect(null as any);
};
return (
<div ref={searchRef} className={cn("relative", className)}>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => searchQuery.length >= 2 && setShowDropdown(true)}
placeholder={placeholder}
className="pl-9 pr-9"
/>
{searchQuery && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
)}
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
{/* Dropdown de resultados */}
{showDropdown && items.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-y-auto">
{items.map((item) => (
<div
key={item.id}
className="p-2 hover:bg-accent cursor-pointer flex items-center gap-3"
onClick={() => handleItemClick(item)}
>
{item.image && (
<div className="relative w-10 h-10 flex-shrink-0 rounded bg-muted">
<Image
src={item.image}
alt={item.name}
fill
style={{ objectFit: "contain" }}
sizes="40px"
/>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.name}</p>
{item.category && (
<p className="text-xs text-muted-foreground">{item.category}</p>
)}
</div>
{item.rarity && (
<Badge variant="secondary" className="text-xs">
{item.rarity}
</Badge>
)}
</div>
))}
</div>
)}
{/* Preview do item selecionado */}
{previewItem && (
<Card className="mt-3">
<CardContent className="p-4">
<div className="flex gap-4">
{previewItem.image && (
<div className="relative w-20 h-20 flex-shrink-0 rounded-lg bg-muted overflow-hidden">
<Image
src={previewItem.image}
alt={previewItem.name}
fill
style={{ objectFit: "contain" }}
sizes="80px"
/>
</div>
)}
<div className="flex-1">
<h4 className="font-semibold text-lg">{previewItem.name}</h4>
<div className="flex flex-wrap gap-2 mt-2 text-sm text-muted-foreground">
{previewItem.category && (
<span className="flex items-center gap-1">
<span className="font-medium">Categoria:</span>
{previewItem.category}
</span>
)}
{previewItem.rarity && (
<Badge variant="secondary">{previewItem.rarity}</Badge>
)}
{previewItem.level !== undefined && previewItem.level !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Level:</span>
{previewItem.level}
</span>
)}
{previewItem.weight !== undefined && previewItem.weight !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Peso:</span>
{previewItem.weight} oz
</span>
)}
{previewItem.npc_price !== undefined && previewItem.npc_price !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Preço NPC:</span>
{previewItem.npc_price.toLocaleString()} gp
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,467 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Eye,
Heart,
Clock,
MapPin,
DollarSign,
Coins,
TrendingUp,
Star,
Pause,
Trash2,
X,
CheckCircle2,
AlertCircle,
ShoppingBag,
} from "lucide-react";
import Image from "next/image";
import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
interface Listing {
listing_id: number;
seller_id: number;
item_id: number;
item_name: string;
quantity: number;
server_name: string;
price_pix: number | string | null;
price_tibia_coins: number | string | null;
price_gold_coins: number | string | null;
is_auction: boolean;
description: string | null;
listing_status: string;
created_at: string;
views_count: number;
favorites_count: number;
is_featured?: boolean;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
current_bid_amount?: number | null;
min_bid_increment?: number | null;
initial_price_pix?: number | null;
initial_price_tibia_coins?: number | null;
initial_price_gold_coins?: number | null;
auction_ends_at?: Date | null;
item_id_unified?: number | null;
image_local_path?: string | null;
image_url?: string | null;
item_category?: string | null;
seller_character_name?: string | null;
}
interface ListingDetailsModalProps {
listing: Listing;
open: boolean;
onOpenChange: (open: boolean) => void;
onPause?: () => void;
onActivate?: () => void;
onCancel?: () => void;
}
function formatTimeAgo(date: Date | string): string {
const dateObj = typeof date === "string" ? new Date(date) : date;
const seconds = Math.floor((new Date().getTime() - dateObj.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24);
if (days < 30) return `há ${days} ${days === 1 ? "dia" : "dias"}`;
const months = Math.floor(days / 30);
if (months < 12) return `há ${months} ${months === 1 ? "mês" : "meses"}`;
const years = Math.floor(months / 12);
return `há ${years} ${years === 1 ? "ano" : "anos"}`;
}
function getStatusBadge(status: string) {
const statusConfig = {
active: { label: "Ativo", variant: "default" as const, icon: CheckCircle2, className: "bg-green-500 hover:bg-green-600" },
sold: { label: "Vendido", variant: "secondary" as const, icon: CheckCircle2, className: "bg-blue-500 hover:bg-blue-600 text-white" },
cancelled: { label: "Cancelado", variant: "destructive" as const, icon: X, className: "bg-red-500 hover:bg-red-600 text-white" },
in_escrow: { label: "Em Escrow", variant: "default" as const, icon: AlertCircle, className: "bg-yellow-500 hover:bg-yellow-600 text-white" },
expired: { label: "Expirado", variant: "secondary" as const, icon: Clock, className: "bg-gray-500 hover:bg-gray-600 text-white" },
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.active;
const Icon = config.icon;
return (
<Badge className={config.className}>
<Icon className="h-3 w-3 mr-1" />
{config.label}
</Badge>
);
}
export function ListingDetailsModal({
listing,
open,
onOpenChange,
onPause,
onActivate,
onCancel,
}: ListingDetailsModalProps) {
const { user } = useSession();
const queryClient = useQueryClient();
const [itemImageUrl, setItemImageUrl] = useState<string>("");
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false);
// Verificar se o usuário é o dono do anúncio
const isOwner = user && user.userId === listing.seller_id;
useEffect(() => {
// Construir URL da imagem
if (listing.image_url) {
setItemImageUrl(listing.image_url);
} else if (listing.image_local_path) {
const normalizedPath = listing.image_local_path.replace(/\\/g, "/");
if (normalizedPath.startsWith("data/")) {
setItemImageUrl(`/api/images?path=${encodeURIComponent(normalizedPath)}`);
} else {
const category = listing.item_category || "other";
setItemImageUrl(`/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`);
}
} else if (listing.item_id_unified) {
const params = new URLSearchParams({ item: listing.item_id_unified.toString() });
if (listing.item_name) {
params.set("name", listing.item_name);
}
setItemImageUrl(`/api/images?${params.toString()}`);
} else {
const params = new URLSearchParams({ item: listing.item_id.toString() });
if (listing.item_name) {
params.set("name", listing.item_name);
}
setItemImageUrl(`/api/images?${params.toString()}`);
}
}, [listing]);
const getAvailablePrices = () => {
const prices: Array<{ type: string; label: string; value: string }> = [];
if (listing.price_pix) {
const price = typeof listing.price_pix === "string" ? parseFloat(listing.price_pix) : listing.price_pix;
if (!isNaN(price)) {
prices.push({ type: "pix", label: "PIX", value: `R$ ${price.toFixed(2)}` });
}
}
if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === "string" ? parseInt(listing.price_tibia_coins, 10) : listing.price_tibia_coins;
if (!isNaN(price)) {
prices.push({ type: "tc", label: "Tibia Coins", value: `${price.toLocaleString()} TC` });
}
}
if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === "string" ? parseInt(listing.price_gold_coins, 10) : listing.price_gold_coins;
if (!isNaN(price)) {
prices.push({ type: "gp", label: "Gold Coins", value: `${price.toLocaleString()} GP` });
}
}
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
prices.push({
type: "trade",
label: "Troca",
value: `${listing.trade_item_quantity}x ${listing.trade_item_name}`,
});
}
return prices;
};
const prices = getAvailablePrices();
// Apenas o dono pode realizar ações
const canPause = isOwner && listing.listing_status === "active";
const canActivate = isOwner && listing.listing_status === "cancelled";
const canCancel = isOwner && listing.listing_status === "active";
const canMarkAsSold = isOwner && (listing.listing_status === "active" || listing.listing_status === "in_escrow");
const handleMarkAsSold = async () => {
setMarkingSold(true);
try {
const res = await fetch(`/api/listings/${listing.listing_id}/mark-sold`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido");
}
// Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
setShowMarkSoldDialog(false);
onOpenChange(false);
} catch (error) {
console.error("Erro ao marcar como vendido:", error);
alert(error instanceof Error ? error.message : "Erro ao marcar como vendido");
} finally {
setMarkingSold(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Detalhes do Anúncio
{getStatusBadge(listing.listing_status)}
</DialogTitle>
<DialogDescription>Informações completas do seu anúncio</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Imagem e informações principais */}
<div className="flex gap-6">
{/* Imagem do item */}
<div className="relative w-48 h-48 flex-shrink-0 rounded-lg overflow-hidden bg-muted flex items-center justify-center p-4 border">
<Image
src={itemImageUrl}
alt={listing.item_name}
fill
className="object-contain"
sizes="192px"
onError={(e) => {
(e.target as HTMLImageElement).src = "/placeholder-item.png";
}}
/>
</div>
{/* Informações do item */}
<div className="flex-1 space-y-4">
<div>
<h3 className="text-2xl font-bold mb-2">{listing.item_name}</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="h-4 w-4" />
<span>Servidor: {listing.server_name}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span>Quantidade: {listing.quantity}</span>
</div>
</div>
{/* Estatísticas */}
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Visualizações</div>
<div className="font-semibold">{listing.views_count}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Favoritos</div>
<div className="font-semibold">{listing.favorites_count || 0}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Criado</div>
<div className="font-semibold text-xs">{formatTimeAgo(listing.created_at)}</div>
</div>
</div>
</div>
{/* Badges */}
<div className="flex flex-wrap gap-2">
{listing.is_featured && (
<Badge variant="default" className="bg-yellow-500">
<Star className="h-3 w-3 mr-1" />
Destaque
</Badge>
)}
{listing.is_auction && (
<Badge variant="secondary">
<TrendingUp className="h-3 w-3 mr-1" />
Leilão
</Badge>
)}
</div>
</div>
</div>
{/* Preços */}
<div className="space-y-2">
<h4 className="font-semibold">Preços</h4>
<div className="grid grid-cols-2 gap-3">
{prices.map((price, index) => (
<div
key={index}
className={`p-3 rounded-lg border ${
price.type === "pix"
? "bg-green-500/10 border-green-500/20"
: price.type === "tc"
? "bg-blue-500/10 border-blue-500/20"
: price.type === "gp"
? "bg-yellow-500/10 border-yellow-500/20"
: "bg-purple-500/10 border-purple-500/20"
}`}
>
<div className="text-xs text-muted-foreground mb-1">{price.label}</div>
<div className="font-semibold">{price.value}</div>
</div>
))}
</div>
</div>
{/* Informações de leilão */}
{listing.is_auction && (
<div className="space-y-2 p-4 bg-muted rounded-lg">
<h4 className="font-semibold">Informações do Leilão</h4>
{listing.current_bid_amount ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Lance Atual:</span>
<span className="font-semibold text-lg text-primary">
R$ {typeof listing.current_bid_amount === "number" ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)}
</span>
</div>
{listing.min_bid_increment && (
<div className="flex justify-between">
<span className="text-muted-foreground">Incremento Mínimo:</span>
<span className="font-medium">
R$ {typeof listing.min_bid_increment === "number" ? listing.min_bid_increment.toFixed(2) : parseFloat(String(listing.min_bid_increment)).toFixed(2)}
</span>
</div>
)}
{listing.auction_ends_at && (
<div className="flex justify-between">
<span className="text-muted-foreground">Termina em:</span>
<span className="font-medium">{new Date(listing.auction_ends_at).toLocaleString("pt-BR")}</span>
</div>
)}
</div>
) : (
<div className="text-center text-muted-foreground py-2">Sem lances ainda</div>
)}
</div>
)}
{/* Descrição */}
{listing.description && (
<div className="space-y-2">
<h4 className="font-semibold">Descrição</h4>
<p className="text-sm text-muted-foreground p-3 bg-muted rounded-lg">{listing.description}</p>
</div>
)}
{/* Informações adicionais */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">ID do Anúncio:</span>
<span className="ml-2 font-mono">{listing.listing_id}</span>
</div>
{listing.item_id_unified && (
<div>
<span className="text-muted-foreground">ID do Item (Unificado):</span>
<span className="ml-2 font-mono">{listing.item_id_unified}</span>
</div>
)}
<div>
<span className="text-muted-foreground">Criado em:</span>
<span className="ml-2">{new Date(listing.created_at).toLocaleString("pt-BR")}</span>
</div>
</div>
{/* Ações */}
{(canPause || canActivate || canCancel || canMarkAsSold) && (
<div className="flex gap-2 pt-4 border-t">
{canMarkAsSold && (
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
className="flex-1 bg-green-600 hover:bg-green-700"
>
<ShoppingBag className="h-4 w-4 mr-2" />
Marcar como Vendido
</Button>
)}
{canPause && (
<Button variant="outline" onClick={onPause} className="flex-1">
<Pause className="h-4 w-4 mr-2" />
Pausar
</Button>
)}
{canActivate && onActivate && (
<Button variant="default" onClick={onActivate} className="flex-1">
<CheckCircle2 className="h-4 w-4 mr-2" />
Reativar Anúncio
</Button>
)}
{canCancel && (
<Button variant="destructive" onClick={onCancel} className="flex-1">
<Trash2 className="h-4 w-4 mr-2" />
Cancelar Anúncio
</Button>
)}
</div>
)}
</div>
</DialogContent>
{/* Dialog de confirmação para marcar como vendido */}
<Dialog open={showMarkSoldDialog} onOpenChange={setShowMarkSoldDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
</DialogDescription>
</DialogHeader>
<div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
<li>O anúncio será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</li>
<li>Após 1 hora, o anúncio sairá automaticamente da lista de anúncios ativos</li>
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul>
<p className="pt-2 font-medium text-foreground">
Tem certeza que deseja marcar este anúncio como vendido?
</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<Button
variant="outline"
onClick={() => setShowMarkSoldDialog(false)}
disabled={markingSold}
>
Cancelar
</Button>
<Button
onClick={handleMarkAsSold}
disabled={markingSold}
className="bg-green-600 hover:bg-green-700"
>
{markingSold ? "Marcando..." : "Sim, marcar como vendido"}
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
);
}
@@ -0,0 +1,227 @@
"use client";
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ShoppingCart, Clock, CheckCircle2, XCircle, MessageSquare } from "lucide-react";
import { IntentionDetailModal } from "./IntentionDetailModal";
interface PurchaseIntention {
intention_id: number;
buyer_id: number;
payment_method: string;
status: "pending" | "accepted" | "rejected" | "expired" | "completed";
buyer_message: string | null;
seller_response: string | null;
created_at: string;
buyer?: {
user_id: number;
username: string;
email: string;
};
}
interface MyAdsIntentionListProps {
listingId: number;
sellerId: number;
}
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);
return `há ${days} ${days === 1 ? "dia" : "dias"}`;
}
function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
function getPaymentMethodLabel(method: string): string {
switch (method) {
case "pix":
return "PIX";
case "tibia_coins":
return "Tibia Coins";
case "gold_coins":
return "Gold Coins";
case "trade":
return "Troca";
default:
return method.toUpperCase();
}
}
export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListProps) {
const [intentions, setIntentions] = useState<PurchaseIntention[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIntention, setSelectedIntention] = useState<PurchaseIntention | null>(null);
const [filter, setFilter] = useState<"all" | "pending" | "accepted" | "rejected">("all");
useEffect(() => {
loadIntentions();
}, [listingId, filter]);
const loadIntentions = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filter !== "all") {
params.set("status", filter);
}
const res = await fetch(`/api/listings/${listingId}/intentions?${params.toString()}`);
if (res.ok) {
const data = await res.json();
if (data.success) {
setIntentions(data.intentions || []);
}
}
} catch (error) {
console.error("Erro ao carregar intenções:", error);
} finally {
setLoading(false);
}
};
const pendingCount = intentions.filter((i) => i.status === "pending").length;
if (loading) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
);
}
if (intentions.length === 0) {
return (
<div className="text-center py-8 text-sm text-muted-foreground">
Nenhuma intenção de compra encontrada
</div>
);
}
return (
<div className="space-y-4">
{/* Filtros */}
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={filter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("all")}
>
Todas ({intentions.length})
</Button>
<Button
variant={filter === "pending" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("pending")}
>
Pendentes ({pendingCount})
</Button>
<Button
variant={filter === "accepted" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("accepted")}
>
Aceitas
</Button>
<Button
variant={filter === "rejected" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("rejected")}
>
Rejeitadas
</Button>
</div>
{/* Lista de intenções */}
<div className="space-y-2">
{intentions.map((intention) => (
<div
key={intention.intention_id}
className="border rounded-lg p-4 hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => setSelectedIntention(intention)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<Avatar className="h-10 w-10">
<AvatarFallback>
{intention.buyer?.username.charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">
{intention.buyer?.username || "Usuário"}
</span>
{getStatusBadge(intention.status)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<ShoppingCart className="h-3 w-3" />
<span>{getPaymentMethodLabel(intention.payment_method)}</span>
<span>•</span>
<Clock className="h-3 w-3" />
<span>{formatTimeAgo(new Date(intention.created_at))}</span>
</div>
{intention.buyer_message && (
<p className="text-xs text-muted-foreground line-clamp-1">
<MessageSquare className="h-3 w-3 inline mr-1" />
{intention.buyer_message}
</p>
)}
</div>
</div>
</div>
</div>
))}
</div>
{/* Modal de detalhes */}
{selectedIntention && (
<IntentionDetailModal
open={!!selectedIntention}
onOpenChange={(open) => {
if (!open) setSelectedIntention(null);
}}
intention={selectedIntention}
listingId={listingId}
onUpdate={loadIntentions}
/>
)}
</div>
);
}
+285
View File
@@ -0,0 +1,285 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Pause,
Play,
Trash2,
Edit,
Eye,
Heart,
TrendingUp,
ChevronDown,
ShoppingCart,
} from "lucide-react";
import { useSession } from "@/lib/hooks/use-session";
import { useMyListings, type Listing } from "@/lib/hooks/use-listings";
import { AdCard } from "./AdCard";
import { MyAdsIntentionList } from "./MyAdsIntentionList";
export function MyAdsPanel() {
const { user } = useSession();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<string>("active");
const [expandedIntentions, setExpandedIntentions] = useState<Set<number>>(new Set());
// Determinar status baseado na aba ativa
const status =
activeTab === "active" ? "active" :
activeTab === "paused" ? "cancelled" : // Backend usa 'cancelled' para pausados
activeTab === "sold" ? "sold" :
undefined; // "all" busca todos
// Usar React Query para buscar meus anúncios com atualização automática
// O queryKey muda quando status muda, então recarrega automaticamente
const { listings, isLoading: loading, refetch } = useMyListings(status);
const invalidateCache = () => {
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
queryClient.invalidateQueries({ queryKey: ["listings"] });
};
const handlePause = async (listingId: number) => {
try {
const res = await fetch(`/api/listings/${listingId}/pause`, {
method: "POST",
});
if (res.ok) {
invalidateCache();
}
} catch (error) {
console.error("Erro ao pausar anúncio:", error);
}
};
const handleActivate = async (listingId: number) => {
try {
const res = await fetch(`/api/listings/${listingId}/activate`, {
method: "POST",
});
if (res.ok) {
invalidateCache();
}
} catch (error) {
console.error("Erro ao reativar anúncio:", error);
}
};
const handleDelete = async (listingId: number) => {
if (!confirm("Tem certeza que deseja excluir este anúncio?")) return;
try {
const res = await fetch(`/api/listings/${listingId}`, {
method: "DELETE",
});
if (res.ok) {
invalidateCache();
}
} catch (error) {
console.error("Erro ao excluir anúncio:", error);
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive"> = {
active: "default",
sold: "secondary",
cancelled: "destructive",
expired: "destructive",
in_escrow: "secondary",
};
const labels: Record<string, string> = {
active: "Ativo",
sold: "Vendido",
cancelled: "Cancelado",
expired: "Expirado",
in_escrow: "Em Escrow",
};
return (
<Badge variant={variants[status] || "secondary"}>
{labels[status] || status}
</Badge>
);
};
if (!user) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
Faça login para ver seus anúncios
</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Meus Anúncios</h2>
<p className="text-muted-foreground mt-1">
Gerencie seus anúncios e acompanhe o desempenho
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="active">Ativos</TabsTrigger>
<TabsTrigger value="paused">Pausados</TabsTrigger>
<TabsTrigger value="sold">Vendidos</TabsTrigger>
<TabsTrigger value="all">Todos</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="mt-6">
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full" />
))}
</div>
) : listings.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum anúncio encontrado
</p>
</div>
) : (
<div className="space-y-4">
{listings.map((listing) => {
const isExpanded = expandedIntentions.has(listing.listing_id);
return (
<div
key={listing.listing_id}
className="border rounded-lg p-4 space-y-4"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-semibold text-lg">
{listing.item_name}
</h3>
{getStatusBadge(listing.listing_status)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Eye className="h-4 w-4" />
<span>{listing.views_count} visualizações</span>
</div>
<div className="flex items-center gap-1">
<Heart className="h-4 w-4" />
<span>{listing.favorites_count} favoritos</span>
</div>
<div className="flex items-center gap-1">
<TrendingUp className="h-4 w-4" />
<span>
{(() => {
if (listing.price_pix) {
const price = typeof listing.price_pix === 'string'
? parseFloat(listing.price_pix)
: listing.price_pix;
return `R$ ${price.toFixed(2)}`;
}
if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === 'string'
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
return `${price.toLocaleString()} TC`;
}
if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === 'string'
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
return `${price.toLocaleString()} GP`;
}
return "Preço não informado";
})()}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{listing.listing_status === "active" && (
<Button
variant="outline"
size="sm"
onClick={() => handlePause(listing.listing_id)}
>
<Pause className="h-4 w-4 mr-1" />
Pausar
</Button>
)}
{listing.listing_status === "cancelled" && (
<Button
variant="outline"
size="sm"
onClick={() => handleActivate(listing.listing_id)}
>
<Play className="h-4 w-4 mr-1" />
Reativar
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(listing.listing_id)}
>
<Trash2 className="h-4 w-4 mr-1" />
Excluir
</Button>
</div>
</div>
{/* Seção de Intenções de Compra */}
{listing.listing_status === "active" && !listing.is_auction && (
<div className="border-t pt-4">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newExpanded = new Set(expandedIntentions);
if (isExpanded) {
newExpanded.delete(listing.listing_id);
} else {
newExpanded.add(listing.listing_id);
}
setExpandedIntentions(newExpanded);
}}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4" />
<span>Intenções de Compra</span>
</div>
<ChevronDown
className={`h-4 w-4 transition-transform ${
isExpanded ? "transform rotate-180" : ""
}`}
/>
</Button>
{isExpanded && (
<div className="mt-4">
<MyAdsIntentionList
listingId={listing.listing_id}
sellerId={listing.seller_id}
/>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { DollarSign, Coins, RefreshCw, Loader2 } from "lucide-react";
import { useSession } from "@/lib/hooks/use-session";
interface Listing {
listing_id: number;
item_name: string;
quantity: number;
price_pix: number | string | null;
price_tibia_coins: number | string | null;
price_gold_coins: number | string | null;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
}
interface PurchaseModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
listing: Listing | null;
onSuccess?: () => void;
}
export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: PurchaseModalProps) {
const { user } = useSession();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!listing) return null;
// Determinar métodos de pagamento disponíveis
const availableMethods: Array<{ type: string; label: string; value: string; icon: any }> = [];
if (listing.price_pix) {
const price = typeof listing.price_pix === 'string'
? parseFloat(listing.price_pix)
: listing.price_pix;
if (!isNaN(price)) {
availableMethods.push({
type: "pix",
label: "PIX",
value: `R$ ${price.toFixed(2)}`,
icon: DollarSign,
});
}
}
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)) {
availableMethods.push({
type: "tc",
label: "Tibia Coins",
value: `${price.toLocaleString()} TC`,
icon: 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)) {
availableMethods.push({
type: "gp",
label: "Gold Coins",
value: `${price.toLocaleString()} GP`,
icon: Coins,
});
}
}
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
availableMethods.push({
type: "trade",
label: "Troca",
value: `Troca por ${listing.trade_item_quantity}x ${listing.trade_item_name}`,
icon: RefreshCw,
});
}
// Se houver apenas um método, selecionar automaticamente
if (availableMethods.length === 1 && !selectedMethod) {
setSelectedMethod(availableMethods[0].type);
}
const handlePurchase = async () => {
if (!selectedMethod) {
setError("Selecione um método de pagamento");
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/listings/${listing.listing_id}/purchase`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
payment_method: selectedMethod,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Erro ao processar compra");
}
// Sucesso
if (onSuccess) {
onSuccess();
}
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao processar compra");
} finally {
setLoading(false);
}
};
const getMethodClassName = (type: string) => {
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 "";
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Confirmar Compra</DialogTitle>
<DialogDescription>
Escolha o método de pagamento para finalizar a compra
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Resumo do item */}
<div className="bg-muted p-4 rounded-lg">
<h3 className="font-semibold mb-2">{listing.item_name}</h3>
<p className="text-sm text-muted-foreground">
Quantidade: {listing.quantity}
</p>
</div>
<Separator />
{/* Seleção de método de pagamento */}
{availableMethods.length > 1 ? (
<div className="space-y-2">
<Label className="text-sm font-medium">Método de Pagamento</Label>
<div className="grid grid-cols-2 gap-2">
{availableMethods.map((method) => {
const Icon = method.icon;
const isSelected = selectedMethod === method.type;
return (
<button
key={method.type}
type="button"
onClick={() => setSelectedMethod(method.type)}
className={`p-4 rounded-lg border-2 transition-all text-left ${
isSelected
? `${getMethodClassName(method.type)} border-primary`
: "border-muted hover:border-primary/50"
}`}
>
<div className="flex items-center gap-2 mb-1">
<Icon className="h-4 w-4" />
<span className="font-medium">{method.label}</span>
</div>
<p className="text-sm opacity-90">{method.value}</p>
</button>
);
})}
</div>
</div>
) : availableMethods.length === 1 ? (
<div className="space-y-2">
<Label className="text-sm font-medium">Método de Pagamento</Label>
<div className={`p-4 rounded-lg border-2 ${getMethodClassName(availableMethods[0].type)}`}>
<div className="flex items-center gap-2 mb-1">
{(() => {
const Icon = availableMethods[0].icon;
return <Icon className="h-4 w-4" />;
})()}
<span className="font-medium">{availableMethods[0].label}</span>
</div>
<p className="text-sm opacity-90">{availableMethods[0].value}</p>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Nenhum método de pagamento disponível
</p>
)}
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
{error}
</div>
)}
<Separator />
{/* Botões */}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancelar
</Button>
<Button
onClick={handlePurchase}
disabled={loading || !selectedMethod || availableMethods.length === 0}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Processando...
</>
) : (
"Confirmar Compra"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { User, CheckCircle2 } from "lucide-react";
import { useEffect } from "react";
import { useMarketTelemetry } from "@/lib/hooks/use-telemetry";
interface SellerInfoProps {
sellerId: number;
sellerName: string;
sellerImage?: string | null;
verified?: boolean;
serverName: string;
listingId: number;
}
export function SellerInfo({
sellerId,
sellerName,
sellerImage,
verified = false,
serverName,
listingId,
}: SellerInfoProps) {
const { trackCustomEvent } = useMarketTelemetry();
// Tracking de visualização de vendedor
useEffect(() => {
trackCustomEvent(
"seller_profile_viewed",
listingId,
{
seller_id: sellerId,
seller_name: sellerName,
}
);
}, [sellerId, sellerName, listingId, trackCustomEvent]);
// Gerar iniciais do nome
const getInitials = (name: string) => {
return name
.split(" ")
.slice(0, 2)
.map((word) => word.charAt(0).toUpperCase())
.join("");
};
return (
<div className="flex items-center gap-2">
<Avatar className="h-10 w-10">
{sellerImage ? (
<img src={sellerImage} alt={sellerName} />
) : (
<AvatarFallback className="bg-muted text-xs">
{getInitials(sellerName)}
</AvatarFallback>
)}
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{sellerName}</span>
{verified && (
<Badge variant="secondary" className="h-4 px-1.5 text-xs">
<CheckCircle2 className="h-3 w-3 mr-1" />
Verificado
</Badge>
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="h-3 w-3" />
<span className="truncate">{serverName}</span>
</div>
</div>
</div>
);
}
+17 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { Menu } from "lucide-react";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -16,8 +16,24 @@ interface MobileMenuProps {
export function MobileMenu({ links }: MobileMenuProps) {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const t = useTranslations("header");
// Evitar hydration mismatch com Radix UI
useEffect(() => {
setMounted(true);
}, []);
// Renderizar placeholder durante SSR para evitar mismatch
if (!mounted) {
return (
<Button variant="outline" size="icon" className="md:hidden" disabled>
<Menu className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">{t("menu")}</span>
</Button>
);
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDropdown } from "./NotificationDropdown";
import { useSession } from "@/lib/hooks/use-session";
interface Notification {
notification_id: number;
notification_type: string;
title: string;
message: string;
is_read: boolean;
related_id: number | null;
created_at: string;
}
interface NotificationBellProps {
userId?: number;
className?: string;
}
export function NotificationBell({ userId, className }: NotificationBellProps) {
const { user } = useSession();
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const effectiveUserId = userId || user?.userId;
// Buscar contagem de não lidas
const fetchUnreadCount = async () => {
if (!effectiveUserId) return;
try {
const res = await fetch("/api/notifications/unread-count");
if (res.ok) {
const data = await res.json();
if (data.success) {
setUnreadCount(data.count);
}
}
} catch (error) {
console.error("Erro ao buscar contagem de notificações:", error);
}
};
// Buscar notificações recentes
const fetchNotifications = async () => {
if (!effectiveUserId) return;
setLoading(true);
try {
const res = await fetch("/api/notifications?limit=10");
if (res.ok) {
const data = await res.json();
if (data.success) {
setNotifications(data.notifications || []);
}
}
} catch (error) {
console.error("Erro ao buscar notificações:", error);
} finally {
setLoading(false);
}
};
// Polling automático a cada 30 segundos
useEffect(() => {
if (!effectiveUserId) return;
fetchUnreadCount();
fetchNotifications();
const interval = setInterval(() => {
fetchUnreadCount();
if (isOpen) {
fetchNotifications();
}
}, 30000); // 30 segundos
return () => clearInterval(interval);
}, [effectiveUserId, isOpen]);
// Atualizar quando abrir popover
useEffect(() => {
if (isOpen) {
fetchNotifications();
}
}, [isOpen]);
const handleMarkAsRead = async (notificationId: number) => {
try {
const res = await fetch(`/api/notifications/${notificationId}/read`, {
method: "POST",
});
if (res.ok) {
// Atualizar estado local
setNotifications((prev) =>
prev.map((n) =>
n.notification_id === notificationId ? { ...n, is_read: true } : n
)
);
// Atualizar contagem
setUnreadCount((prev) => Math.max(0, prev - 1));
}
} catch (error) {
console.error("Erro ao marcar notificação como lida:", error);
}
};
if (!effectiveUserId) {
return null;
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className={`relative ${className}`}>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs rounded-full animate-pulse"
>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 p-0" align="end">
<NotificationDropdown
notifications={notifications}
onMarkAsRead={handleMarkAsRead}
onViewAll={() => {
setIsOpen(false);
// Navegar para página de notificações (implementar depois)
window.location.href = "/notifications";
}}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,159 @@
"use client";
import { Button } from "@/components/ui/button";
// ScrollArea não disponível, usando div com overflow
import { Separator } from "@/components/ui/separator";
import { Bell, ShoppingCart, TrendingUp, MessageSquare, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useLocale } from "next-intl";
interface Notification {
notification_id: number;
notification_type: string;
title: string;
message: string;
is_read: boolean;
related_id: number | null;
created_at: string;
}
interface NotificationDropdownProps {
notifications: Notification[];
onMarkAsRead: (notificationId: number) => Promise<void>;
onViewAll?: () => void;
}
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"}`;
}
function getNotificationIcon(type: string) {
switch (type) {
case "purchase":
case "sale":
return <ShoppingCart className="h-4 w-4" />;
case "bid":
return <TrendingUp className="h-4 w-4" />;
case "system":
return <Bell className="h-4 w-4" />;
default:
return <MessageSquare className="h-4 w-4" />;
}
}
function getNotificationColor(type: string) {
switch (type) {
case "purchase":
case "sale":
return "bg-green-500";
case "bid":
return "bg-blue-500";
case "system":
return "bg-yellow-500";
default:
return "bg-gray-500";
}
}
export function NotificationDropdown({
notifications,
onMarkAsRead,
onViewAll,
}: NotificationDropdownProps) {
const locale = useLocale();
const handleNotificationClick = async (notification: Notification) => {
if (!notification.is_read) {
await onMarkAsRead(notification.notification_id);
}
};
if (notifications.length === 0) {
return (
<div className="w-80 p-4 text-center text-sm text-muted-foreground">
Nenhuma notificação
</div>
);
}
return (
<div className="w-80 flex flex-col">
<div className="p-4 border-b">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Notificações</h3>
{onViewAll && (
<Button
variant="ghost"
size="sm"
onClick={onViewAll}
className="h-auto p-0 text-xs"
>
Ver todas
</Button>
)}
</div>
</div>
<div className="h-[400px] overflow-y-auto">
<div className="p-2">
{notifications.map((notification, index) => (
<div key={notification.notification_id}>
<button
onClick={() => handleNotificationClick(notification)}
className={`w-full text-left p-3 rounded-lg hover:bg-muted transition-colors ${
!notification.is_read ? "bg-muted/50" : ""
}`}
>
<div className="flex items-start gap-3">
<div
className={`${getNotificationColor(
notification.notification_type
)} rounded-full p-2 text-white flex-shrink-0`}
>
{getNotificationIcon(notification.notification_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4 className="font-medium text-sm">{notification.title}</h4>
{!notification.is_read && (
<div className="h-2 w-2 bg-primary rounded-full flex-shrink-0 mt-1" />
)}
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{notification.message}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatTimeAgo(new Date(notification.created_at))}
</p>
{notification.related_id && (
<Link
href={`/${locale}/market`}
onClick={(e) => e.stopPropagation()}
className="text-xs text-primary hover:underline mt-1 inline-flex items-center gap-1"
>
Ver anúncio <ExternalLink className="h-3 w-3" />
</Link>
)}
</div>
</div>
</button>
{index < notifications.length - 1 && <Separator className="my-2" />}
</div>
))}
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+11 -1
View File
@@ -5,7 +5,17 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const Dialog = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root>
>(({ ...props }, ref) => (
<DialogPrimitive.Root
ref={ref}
suppressHydrationWarning
{...props}
/>
));
Dialog.displayName = "Dialog";
const DialogTrigger = DialogPrimitive.Trigger;
+7 -1
View File
@@ -9,7 +9,13 @@ import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return (
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
suppressHydrationWarning
{...props}
/>
);
}
function DropdownMenuPortal({
+161
View File
@@ -0,0 +1,161 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+7 -1
View File
@@ -7,7 +7,13 @@ import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return (
<SheetPrimitive.Root
data-slot="sheet"
suppressHydrationWarning
{...props}
/>
);
}
function SheetTrigger({
+163
View File
@@ -0,0 +1,163 @@
# 🔍 Análise dos Erros Encontrados
## 📋 Problemas Identificados
### 1. ❌ Erro: "Decimal objects are not supported" (Linha 77-78)
**Causa:**
- O Prisma retorna campos `Decimal` (PostgreSQL) como objetos `Prisma.Decimal`, não como números JavaScript
- O Next.js não consegue serializar objetos `Decimal` ao passar dados de Server Components para Client Components
- Apenas objetos "plain" (números, strings, arrays, objetos simples) podem ser serializados
**Campos afetados:**
- `price_pix` (Decimal)
- `current_bid_amount` (Decimal)
- `min_bid_increment` (Decimal)
- `initial_price_pix` (Decimal)
**Solução aplicada:**
✅ Converter todos os campos `Decimal` para `number` usando `Number()` antes de retornar os dados
---
### 2. ⚠️ Warning: "Package postcss can't be external" (Linhas 15-20, 85-90, etc.)
**Causa:**
- Dependência transitiva (`sanitize-html` → `postcss`) não está instalada diretamente
- Next.js/Turbopack tenta externalizar o pacote mas não consegue resolvê-lo
**Impacto:**
- ⚠️ Apenas um warning, não quebra a aplicação
- Pode causar problemas em builds de produção
**Soluções possíveis:**
1. **Instalar postcss diretamente** (recomendado):
```bash
pnpm add postcss
```
2. **Adicionar ao package.json** explicitamente:
```json
"dependencies": {
"postcss": "^8.4.0"
}
```
---
### 3. ✅ Erro resolvido: "column does not exist" (Linha 45)
**Status:** ✅ **RESOLVIDO**
- A migração 014 foi executada com sucesso
- As colunas `min_bid_increment`, `initial_price_pix`, etc. foram adicionadas
- O erro não aparece mais nos logs recentes (linhas 75-76, 115-116 mostram queries funcionando)
---
## 🔧 Correções Implementadas
### Arquivo: `lib/prisma/queries/listings.ts`
**Antes:**
```typescript
return {
...listing,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
// ❌ Campos Decimal não eram convertidos
};
```
**Depois:**
```typescript
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
} as Listing;
```
**Funções corrigidas:**
- ✅ `createListing()`
- ✅ `getListingById()`
- ✅ `getListings()`
- ✅ `updateListing()`
- ✅ `placeBid()`
---
## 📊 Resultado
### ✅ Resolvido:
1. Erro de coluna não existente (migração executada)
2. Erro de serialização Decimal (conversão implementada)
### ⚠️ Pendente (não crítico):
1. Warning do postcss (pode ser ignorado ou resolvido instalando postcss)
---
## 🎯 Próximos Passos Recomendados
1. **Instalar postcss** (opcional, resolve warning):
```bash
pnpm add postcss
```
2. **Testar a aplicação:**
- Verificar se a página do marketplace carrega corretamente
- Verificar se os preços são exibidos corretamente
- Testar criação de anúncios
- Testar leilões e lances
3. **Monitorar logs:**
- Verificar se o erro "Decimal objects are not supported" não aparece mais
- Verificar se as queries estão funcionando corretamente
---
## 🔍 Por que isso acontece?
### Prisma + PostgreSQL + Decimal
O Prisma usa o tipo `Decimal` do Prisma para representar valores `DECIMAL` do PostgreSQL. Isso garante precisão decimal, mas cria objetos que não são serializáveis pelo React/Next.js.
**Fluxo do problema:**
```
PostgreSQL DECIMAL → Prisma.Decimal → ❌ Não serializável → Erro no React
```
**Solução:**
```
PostgreSQL DECIMAL → Prisma.Decimal → Number() → ✅ number (serializável)
```
### Serialização no Next.js
O Next.js precisa serializar dados ao passar de Server Components para Client Components. Ele usa JSON serialization, que não suporta:
- Classes customizadas
- Objetos `Decimal` do Prisma
- Funções
- Símbolos
- etc.
Apenas tipos primitivos JSON são suportados:
- ✅ `string`, `number`, `boolean`
- ✅ `null`, `undefined`
- ✅ Arrays e objetos simples
---
## 📚 Referências
- [Prisma Decimal Type](https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-decimal-numbers)
- [Next.js Serialization](https://nextjs.org/docs/app/building-your-application/data-fetching/patterns#async-server-components)
- [React Server Components](https://react.dev/reference/rsc/server-components)
+14
View File
@@ -0,0 +1,14 @@
## 📋 ANÁLISE DO PROBLEMA
### Problema Identificado
- O sistema usa SQLite com múltiplas tabelas (espadas, armaduras, etc)
- Cada tabela tem seus próprios IDs que podem se repetir entre tabelas
- Quando salvamos `item_id=1` no listing, não sabemos de qual tabela veio
- Ao buscar com `getItemById(1)`, pode retornar o item errado
### Solução Proposta
1. Criar tabela unificada `items` no PostgreSQL com IDs únicos
2. Popular a tabela a partir do SQLite
3. Usar JOINs nas queries para garantir consistência
4. Atualizar sistema para usar sempre o ID unificado
+798
View File
@@ -0,0 +1,798 @@
# 📋 Plano Detalhado: Melhorias do Marketplace
## 🎯 Objetivo Geral
Implementar um sistema completo de intenções de compra, melhorias na interface de gerenciamento de anúncios, sistema de notificações visual, exibição de vendedores, sistema de lances e telemetria completa.
---
## 📊 Análise de Requisitos
### Requisitos Explícitos
1. **Sistema de Intenção de Compra**
- Compra não remove item do mercado
- Criar "intenção de compra" com método de pagamento escolhido
- Notificar vendedor sobre cada intenção
2. **Melhorias na Página "Meus Anúncios"**
- Exibir dados de compradores interessados
- Mostrar informações de cada intenção de compra
3. **Ícone de Notificações**
- Badge com contador de notificações não lidas
- Visual no header/navbar
4. **Exibição de Vendedor**
- Nome do vendedor nos cards
- Avatar/imagem do vendedor (preparar para integração futura)
5. **Sistema de Lances (Bids)**
- Permitir que usuários deem lances
- Atualizar valor do item conforme lances
- Integração completa no frontend
6. **Telemetria Completa**
- Rastrear lances (valor, horário, usuário)
- Acessos por dia/hora
- Anúncios mais clicados
- Análises de comportamento
### Requisitos Implícitos
- Sistema deve ser escalável
- Performance não deve degradar com muitos dados
- Interface responsiva e intuitiva
- Notificações em tempo real (ou polling eficiente)
---
## 🗂️ Arquivos Afetados
### Backend (API + Queries)
1. **Novos Arquivos:**
- `lib/db/migrations/015_add_purchase_intentions.sql`
- `lib/prisma/queries/purchase-intentions.ts`
- `app/api/listings/[id]/intentions/route.ts`
- `app/api/listings/[id]/intentions/[intentionId]/route.ts`
- `app/api/notifications/unread-count/route.ts`
2. **Arquivos Modificados:**
- `app/api/listings/[id]/purchase/route.ts` (mudar para criar intenção)
- `lib/prisma/queries/listings.ts` (adicionar queries relacionadas)
- `lib/prisma/queries/notifications.ts` (melhorar queries)
- `lib/prisma/queries/telemetry.ts` (adicionar eventos de lance)
### Frontend (Components)
1. **Novos Arquivos:**
- `components/notifications/NotificationBell.tsx`
- `components/notifications/NotificationDropdown.tsx`
- `components/marketplace/PurchaseIntentionModal.tsx`
- `components/marketplace/MyAdsIntentionList.tsx`
- `components/marketplace/SellerInfo.tsx`
2. **Arquivos Modificados:**
- `components/marketplace/PurchaseModal.tsx` (mudar para intenção)
- `components/marketplace/MyAdsPanel.tsx` (adicionar seção de intenções)
- `components/marketplace/AdCard.tsx` (adicionar SellerInfo, melhorar bids)
- `components/Header.tsx` (adicionar NotificationBell)
- `components/marketplace/BidModal.tsx` (melhorar UI e telemetria)
---
## 🗄️ Estrutura de Banco de Dados
### Nova Tabela: `purchase_intentions`
```sql
CREATE TABLE purchase_intentions (
intention_id SERIAL PRIMARY KEY,
listing_id INTEGER NOT NULL REFERENCES listings(listing_id) ON DELETE CASCADE,
buyer_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
payment_method VARCHAR(20) NOT NULL CHECK (payment_method IN ('pix', 'tibia_coins', 'gold_coins', 'trade')),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'expired', 'completed')),
buyer_message TEXT,
seller_response TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
accepted_at TIMESTAMP,
rejected_at TIMESTAMP,
UNIQUE(listing_id, buyer_id, status) WHERE status = 'pending',
INDEX idx_listing_id (listing_id),
INDEX idx_buyer_id (buyer_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
);
```
**Campos:**
- `intention_id`: ID único
- `listing_id`: Anúncio relacionado
- `buyer_id`: Comprador interessado
- `payment_method`: Método de pagamento escolhido
- `status`: Estado da intenção (pending, accepted, rejected, expired, completed)
- `buyer_message`: Mensagem opcional do comprador
- `seller_response`: Resposta do vendedor
- `expires_at`: Quando a intenção expira (opcional)
- Timestamps para rastreamento
---
## 📝 Plano de Implementação
## FASE 1: Sistema de Intenções de Compra (Backend)
### Etapa 1.1: Migração de Banco de Dados
**Arquivo:** `lib/db/migrations/015_add_purchase_intentions.sql`
```sql
-- Migration 015: Sistema de Intenções de Compra
-- Criado em: 2025-01-29
CREATE TABLE IF NOT EXISTS purchase_intentions (
intention_id SERIAL PRIMARY KEY,
listing_id INTEGER NOT NULL REFERENCES listings(listing_id) ON DELETE CASCADE,
buyer_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
payment_method VARCHAR(20) NOT NULL CHECK (payment_method IN ('pix', 'tibia_coins', 'gold_coins', 'trade')),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'expired', 'completed')),
buyer_message TEXT,
seller_response TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
accepted_at TIMESTAMP,
rejected_at TIMESTAMP
);
-- Índices para performance
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_listing_id ON purchase_intentions(listing_id);
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_buyer_id ON purchase_intentions(buyer_id);
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_status ON purchase_intentions(status);
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_created_at ON purchase_intentions(created_at DESC);
-- Constraint: apenas uma intenção pendente por listing+buyer
CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_intentions_unique_pending
ON purchase_intentions(listing_id, buyer_id)
WHERE status = 'pending';
-- Trigger para atualizar updated_at
CREATE OR REPLACE FUNCTION update_purchase_intentions_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_purchase_intentions_updated_at
BEFORE UPDATE ON purchase_intentions
FOR EACH ROW
EXECUTE FUNCTION update_purchase_intentions_updated_at();
-- Comentários para documentação
COMMENT ON TABLE purchase_intentions IS 'Intenções de compra de anúncios - permite múltiplos compradores interessados';
COMMENT ON COLUMN purchase_intentions.status IS 'pending: aguardando resposta do vendedor, accepted: aceita, rejected: rejeitada, expired: expirada, completed: concluída';
```
**Tarefas:**
- [ ] Criar arquivo de migração
- [ ] Adicionar à lista de migrations em `run-migrations.ts`
- [ ] Executar migração
- [ ] Atualizar schema Prisma
---
### Etapa 1.2: Schema Prisma
**Arquivo:** `prisma/schema.prisma`
```prisma
model PurchaseIntention {
intention_id Int @id @default(autoincrement())
listing_id Int
buyer_id Int
payment_method String @db.VarChar(20)
status String @default("pending") @db.VarChar(20)
buyer_message String? @db.Text
seller_response String? @db.Text
created_at DateTime @default(now())
updated_at DateTime @updatedAt
expires_at DateTime?
accepted_at DateTime?
rejected_at DateTime?
listing Listing @relation(fields: [listing_id], references: [listing_id], onDelete: Cascade)
buyer User @relation("BuyerIntentions", fields: [buyer_id], references: [user_id], onDelete: Cascade)
@@index([listing_id])
@@index([buyer_id])
@@index([status])
@@index([created_at(sort: Desc)])
@@unique([listing_id, buyer_id], where: { status: "pending" })
@@map("purchase_intentions")
}
```
**Também atualizar:**
- Adicionar relação `purchaseIntentions` no model `Listing`
- Adicionar relação `purchaseIntentions` no model `User` (como `BuyerIntentions`)
---
### Etapa 1.3: Queries Prisma
**Arquivo:** `lib/prisma/queries/purchase-intentions.ts`
**Funções a criar:**
1. `createPurchaseIntention(data)` - Criar intenção
2. `getPurchaseIntentionsByListing(listingId, filters)` - Listar intenções de um anúncio
3. `getPurchaseIntentionsByBuyer(buyerId, filters)` - Listar intenções de um comprador
4. `getPurchaseIntentionById(intentionId)` - Buscar intenção específica
5. `updatePurchaseIntentionStatus(intentionId, status, sellerResponse)` - Atualizar status
6. `getPendingIntentionsCount(listingId)` - Contar intenções pendentes
**Detalhamento:**
```typescript
export interface CreatePurchaseIntentionData {
listing_id: number;
buyer_id: number;
payment_method: "pix" | "tibia_coins" | "gold_coins" | "trade";
buyer_message?: string;
expires_at?: Date;
}
export interface PurchaseIntention {
intention_id: number;
listing_id: number;
buyer_id: number;
payment_method: string;
status: "pending" | "accepted" | "rejected" | "expired" | "completed";
buyer_message: string | null;
seller_response: string | null;
created_at: Date;
updated_at: Date;
expires_at: Date | null;
accepted_at: Date | null;
rejected_at: Date | null;
// Relacionamentos
listing?: Listing;
buyer?: User;
}
```
---
### Etapa 1.4: API Routes
**Arquivo:** `app/api/listings/[id]/intentions/route.ts`
**Endpoints:**
- `POST /api/listings/[id]/intentions` - Criar intenção de compra
- `GET /api/listings/[id]/intentions` - Listar intenções (apenas vendedor)
**Arquivo:** `app/api/listings/[id]/intentions/[intentionId]/route.ts`
**Endpoints:**
- `GET /api/listings/[id]/intentions/[intentionId]` - Buscar intenção
- `PATCH /api/listings/[id]/intentions/[intentionId]` - Atualizar status (aceitar/rejeitar)
**Arquivo:** `app/api/listings/[id]/purchase/route.ts` (MODIFICAR)
**Mudança:**
- Ao invés de mudar `listing_status` para "sold", criar uma `PurchaseIntention`
- Notificar vendedor sobre nova intenção
---
## FASE 2: Sistema de Notificações Visual
### Etapa 2.1: API de Contagem de Notificações Não Lidas
**Arquivo:** `app/api/notifications/unread-count/route.ts`
```typescript
// GET /api/notifications/unread-count
// Retorna: { count: number }
```
**Já existe função `getUnreadCount()` em `lib/prisma/queries/notifications.ts` - apenas criar rota**
---
### Etapa 2.2: Componente NotificationBell
**Arquivo:** `components/notifications/NotificationBell.tsx`
**Funcionalidades:**
- Badge com contador de não lidas
- Ícone de sino (Bell do lucide-react)
- Dropdown ao clicar (usar `NotificationDropdown`)
- Polling automático a cada 30 segundos
- Animações quando há novas notificações
**Props:**
```typescript
interface NotificationBellProps {
userId?: number;
className?: string;
}
```
**Estado:**
- `unreadCount: number`
- `isOpen: boolean`
- `notifications: Notification[]`
---
### Etapa 2.3: Componente NotificationDropdown
**Arquivo:** `components/notifications/NotificationDropdown.tsx`
**Funcionalidades:**
- Lista de notificações recentes (últimas 10)
- Link "Ver todas" para página completa
- Marcar como lida ao clicar
- Diferentes tipos de notificação (compra, lance, etc.)
- Timestamps formatados
---
### Etapa 2.4: Integrar no Header
**Arquivo:** `components/Header.tsx`
**Mudanças:**
- Adicionar `NotificationBell` no navbar
- Posicionar ao lado do menu de usuário
- Apenas exibir se usuário estiver logado
---
## FASE 3: Exibição de Vendedor
### Etapa 3.1: Componente SellerInfo
**Arquivo:** `components/marketplace/SellerInfo.tsx`
**Funcionalidades:**
- Avatar do vendedor (placeholder inicial, preparado para imagem)
- Nome do personagem
- Badge "Verificado" se aplicável
- Link para perfil do vendedor (futuro)
**Props:**
```typescript
interface SellerInfoProps {
sellerId: number;
sellerName: string;
sellerImage?: string | null;
verified?: boolean;
serverName: string;
}
```
**Design:**
- Avatar circular com fallback (iniciais do nome)
- Layout horizontal compacto
- Responsivo
---
### Etapa 3.2: Integrar no AdCard
**Arquivo:** `components/marketplace/AdCard.tsx`
**Mudanças:**
- Adicionar `SellerInfo` no card (modo grid e list)
- Posicionar acima ou abaixo do preço
- Estilizar consistentemente
---
## FASE 4: Sistema de Lances (Bids) - Frontend Completo
### Etapa 4.1: Melhorar BidModal
**Arquivo:** `components/marketplace/BidModal.tsx`
**Melhorias:**
- Exibir histórico de lances (últimos 5)
- Mostrar "Seu lance atual" se usuário já deu lance
- Indicador visual de lance mínimo
- Feedback imediato após dar lance
- Refresh automático do valor do anúncio
**Novos campos:**
- Histórico de lances
- Timestamp do lance
- Informações do último lanceador
---
### Etapa 4.2: API de Histórico de Lances
**Arquivo:** `app/api/listings/[id]/bids/route.ts`
**Endpoint:**
- `GET /api/listings/[id]/bids` - Listar lances de um anúncio
**Resposta:**
```typescript
{
bids: Array<{
bid_id: number;
bidder_id: number;
bidder_name: string;
bid_amount: number;
bid_type: string | null;
created_at: Date;
}>;
total: number;
}
```
---
### Etapa 4.3: Atualizar BidModal com Histórico
**Mudanças:**
- Buscar histórico ao abrir modal
- Exibir lista de lances anteriores
- Mostrar "Você está na liderança" se aplicável
- Atualizar valor mínimo dinamicamente
---
### Etapa 4.4: Melhorar AdCard para Bids
**Arquivo:** `components/marketplace/AdCard.tsx`
**Melhorias:**
- Exibir "X lances" ao invés de apenas valor atual
- Indicador visual de leilão ativo
- Contador regressivo se `auction_ends_at` estiver definido
- Badge "Novo lance" se houver lance recente
---
## FASE 5: Telemetria Completa
### Etapa 5.1: Expandir Eventos de Telemetria
**Arquivo:** `lib/prisma/queries/telemetry.ts`
**Novos tipos de eventos:**
- `bid_placed` - Lance realizado
- `bid_viewed` - Histórico de lances visualizado
- `purchase_intention_created` - Intenção de compra criada
- `purchase_intention_viewed` - Intenção visualizada
- `seller_profile_viewed` - Perfil do vendedor visualizado
**Atualizar interface:**
```typescript
export type TelemetryEventType =
| "ad_view"
| "ad_click"
| "ad_exit"
| "search"
| "filter"
| "favorite"
| "unfavorite"
| "purchase"
| "purchase_intention_created" // NOVO
| "purchase_intention_viewed" // NOVO
| "bid_placed" // NOVO
| "bid_viewed" // NOVO
| "seller_profile_viewed" // NOVO
| "inquiry"
| "page_view"
| "price_change";
```
---
### Etapa 5.2: Tracking de Lances
**Arquivo:** `app/api/listings/[id]/bid/route.ts`
**Mudanças:**
- Após criar lance com sucesso, registrar telemetria:
```typescript
await trackEvent({
event_type: "bid_placed",
ad_id: listingId,
user_id: bidderId,
metadata: {
bid_amount: amount,
payment_method: paymentMethod,
previous_bid: listing.current_bid_amount,
min_increment: listing.min_bid_increment,
},
});
```
---
### Etapa 5.3: Tracking de Intenções de Compra
**Arquivo:** `app/api/listings/[id]/intentions/route.ts`
**Mudanças:**
- Após criar intenção, registrar telemetria:
```typescript
await trackEvent({
event_type: "purchase_intention_created",
ad_id: listingId,
user_id: buyerId,
metadata: {
payment_method: paymentMethod,
listing_price_pix: listing.price_pix,
listing_price_tc: listing.price_tibia_coins,
listing_price_gp: listing.price_gold_coins,
},
});
```
---
### Etapa 5.4: Tracking de Visualizações de Vendedor
**Arquivo:** `components/marketplace/SellerInfo.tsx`
**Mudanças:**
- Ao montar componente, registrar telemetria:
```typescript
useEffect(() => {
trackEvent({
event_type: "seller_profile_viewed",
ad_id: listingId,
metadata: {
seller_id: sellerId,
seller_name: sellerName,
},
});
}, [sellerId, listingId]);
```
---
### Etapa 5.5: Analytics Dashboard (Opcional - Futuro)
**Arquivo:** `components/marketplace/AnalyticsDashboard.tsx` (já existe)
**Melhorias futuras:**
- Gráfico de lances ao longo do tempo
- Anúncios mais clicados
- Horários de pico de acesso
- Taxa de conversão de intenções
---
## FASE 6: Melhorias na Página "Meus Anúncios"
### Etapa 6.1: Componente MyAdsIntentionList
**Arquivo:** `components/marketplace/MyAdsIntentionList.tsx`
**Funcionalidades:**
- Lista de intenções de compra para um anúncio específico
- Filtros (pendentes, aceitas, rejeitadas)
- Ações: aceitar, rejeitar, ver detalhes
- Badge com contador de pendentes
**Props:**
```typescript
interface MyAdsIntentionListProps {
listingId: number;
sellerId: number;
}
```
---
### Etapa 6.2: Atualizar MyAdsPanel
**Arquivo:** `components/marketplace/MyAdsPanel.tsx`
**Mudanças:**
- Adicionar seção de intenções em cada card de anúncio
- Expandir/colapsar intenções
- Mostrar resumo: "X intenções pendentes"
- Link para ver todas as intenções
**Layout:**
```
┌─────────────────────────────────┐
│ [Anúncio: Item X] │
│ Status: Active │
│ │
│ 📊 Intenções de Compra │
│ └─ 3 pendentes │
│ [Ver todas] [Expandir] │
│ │
│ 💰 Lances (se for leilão) │
│ └─ 5 lances, atual: R$ 100 │
│ [Ver histórico] │
└─────────────────────────────────┘
```
---
### Etapa 6.3: Modal de Detalhes de Intenção
**Arquivo:** `components/marketplace/IntentionDetailModal.tsx`
**Funcionalidades:**
- Exibir informações completas da intenção
- Dados do comprador (nome, personagem)
- Método de pagamento escolhido
- Mensagem do comprador (se houver)
- Botões: Aceitar / Rejeitar
- Campo para resposta do vendedor
---
## 📊 Sequência de Implementação Recomendada
### Sprint 1: Fundação (Backend)
1. ✅ Migração de banco de dados (Fase 1.1)
2. ✅ Schema Prisma (Fase 1.2)
3. ✅ Queries Prisma (Fase 1.3)
4. ✅ API Routes básicas (Fase 1.4)
### Sprint 2: Frontend Core
5. ✅ Componente SellerInfo (Fase 3.1)
6. ✅ Integrar SellerInfo no AdCard (Fase 3.2)
7. ✅ NotificationBell e Dropdown (Fase 2.1-2.3)
8. ✅ Integrar no Header (Fase 2.4)
### Sprint 3: Sistema de Intenções
9. ✅ PurchaseIntentionModal (Fase 1 - Frontend)
10. ✅ Modificar PurchaseModal para criar intenção
11. ✅ MyAdsIntentionList (Fase 6.1)
12. ✅ Atualizar MyAdsPanel (Fase 6.2)
### Sprint 4: Sistema de Lances
13. ✅ API de histórico de lances (Fase 4.2)
14. ✅ Melhorar BidModal com histórico (Fase 4.1, 4.3)
15. ✅ Melhorar AdCard para bids (Fase 4.4)
### Sprint 5: Telemetria
16. ✅ Expandir eventos (Fase 5.1)
17. ✅ Tracking de lances (Fase 5.2)
18. ✅ Tracking de intenções (Fase 5.3)
19. ✅ Tracking de vendedor (Fase 5.4)
---
## 🎨 Design e UX
### Componentes Visuais
1. **NotificationBell:**
- Ícone: `Bell` do lucide-react
- Badge vermelho com contador
- Animação de pulse quando há novas
- Dropdown com sombra e bordas arredondadas
2. **SellerInfo:**
- Avatar: 40x40px, circular
- Nome: texto médio, bold
- Badge verificado: ícone de check, verde
- Layout: flex horizontal, espaçamento adequado
3. **IntentionList:**
- Cards compactos por intenção
- Badge de status (pendente: amarelo, aceita: verde, rejeitada: vermelho)
- Avatar do comprador
- Método de pagamento com ícone
4. **BidHistory:**
- Lista vertical com timestamps
- Destaque para lance atual
- Indicador de "seu lance"
---
## ⚠️ Considerações e Riscos
### Riscos Identificados
1. **Performance:**
- Muitas intenções por anúncio pode degradar performance
- **Mitigação:** Paginação e lazy loading
2. **Notificações:**
- Polling pode sobrecarregar servidor
- **Mitigação:** Polling inteligente (mais frequente quando há novas notificações)
3. **Concorrência:**
- Múltiplos lances simultâneos
- **Mitigação:** Transações e validação de incremento mínimo
4. **Telemetria:**
- Muitos eventos podem sobrecarregar banco
- **Mitigação:** Batch de eventos, agregação periódica
### Validações Necessárias
- ✅ Verificar se método de pagamento escolhido está disponível no anúncio
- ✅ Validar que comprador não é o vendedor
- ✅ Validar incremento mínimo de lance
- ✅ Validar que anúncio ainda está ativo
- ✅ Validar que leilão não expirou
---
## 🧪 Testes Necessários
### Testes Unitários
- [ ] Queries de purchase_intentions
- [ ] Validação de métodos de pagamento
- [ ] Cálculo de incremento mínimo de lance
### Testes de Integração
- [ ] Fluxo completo de criação de intenção
- [ ] Notificação ao vendedor
- [ ] Aceitar/rejeitar intenção
- [ ] Sistema de lances com múltiplos usuários
### Testes E2E
- [ ] Comprador cria intenção → Vendedor recebe notificação
- [ ] Vendedor aceita intenção → Comprador é notificado
- [ ] Múltiplos lances → Valor atualiza corretamente
- [ ] Telemetria registra todos os eventos
---
## 📈 Métricas de Sucesso
1. **Funcionalidade:**
- ✅ Intenções de compra funcionando
- ✅ Notificações aparecendo corretamente
- ✅ Lances atualizando valor
- ✅ Vendedores aparecendo nos cards
2. **Performance:**
- Página de marketplace carrega em < 2s
- Notificações atualizam em < 1s
- Queries de intenções em < 500ms
3. **UX:**
- Interface intuitiva e responsiva
- Feedback visual claro
- Mensagens de erro compreensíveis
---
## 🚀 Próximos Passos
1. **Revisar este plano** e ajustar conforme necessário
2. **Priorizar fases** baseado em necessidade do negócio
3. **Iniciar Fase 1** (Backend de intenções)
4. **Testar incrementalmente** cada fase antes de avançar
---
## 📚 Referências
- [Prisma Relations](https://www.prisma.io/docs/concepts/components/prisma-schema/relations)
- [Next.js Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations)
- [React Query/Polling](https://tanstack.com/query/latest/docs/react/guides/window-focus-refetching)
---
**Plano criado em:** 2025-01-29
**Versão:** 1.0
**Status:** Pronto para implementação
+288
View File
@@ -0,0 +1,288 @@
# 📊 Prisma Query Logs - Explicação Completa
## 🔍 O que são os logs `prisma:query`?
Os logs `prisma:query` são **queries SQL que o Prisma executa automaticamente** no banco de dados PostgreSQL. Eles aparecem no console durante o desenvolvimento para ajudar você a:
1. **Debugar** - Ver exatamente quais queries estão sendo executadas
2. **Otimizar** - Identificar queries lentas ou desnecessárias
3. **Entender** - Compreender o que o Prisma está fazendo "por baixo dos panos"
---
## ⚙️ Configuração Atual
No arquivo `lib/prisma/app.ts`, o Prisma está configurado assim:
```typescript
export const prisma = new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"] // ← Loga queries em desenvolvimento
: ["error"], // ← Apenas erros em produção
});
```
**Isso significa:**
- ✅ **Em desenvolvimento**: Mostra todas as queries SQL
- ✅ **Em produção**: Apenas mostra erros (não mostra queries)
---
## 📝 Análise dos Logs do Seu Terminal
### 1. **INSERT de Telemetria** (Linhas 24-26, 58-59, 93-94)
```sql
INSERT INTO "public"."telemetry_events"
("event_type","ad_id","user_id","session_id","metadata","timestamp")
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING ...
```
**O que é:**
- Registra eventos de telemetria (cliques, visualizações, etc.)
- Executado quando o usuário interage com a página
**Por que aparece:**
- O sistema de telemetria está ativo e registrando eventos
---
### 2. **SELECT de Listings** (Linhas 35-36, 79-80)
```sql
SELECT COUNT(*) AS "_count$_all"
FROM (SELECT "public"."listings"."listing_id"
FROM "public"."listings"
WHERE "public"."listings"."listing_status" = $1
OFFSET $2) AS "sub"
```
**O que é:**
- Conta quantos anúncios ativos existem
- Usado para paginação
**E depois:**
```sql
SELECT "public"."listings"."listing_id",
"public"."listings"."seller_id",
"public"."listings"."item_id",
-- ... todos os campos ...
FROM "public"."listings"
WHERE "public"."listings"."listing_status" = $1
ORDER BY "public"."listings"."created_at" DESC
LIMIT $2 OFFSET $3
```
**O que é:**
- Busca os anúncios ativos com paginação
- Ordena por data de criação (mais recentes primeiro)
- Limita a 20 resultados por página
**Por que aparece:**
- A página do marketplace (`/en/market`) está carregando
- Está buscando anúncios para exibir
---
### 3. **SELECT de Characters** (Linhas 37, 81)
```sql
SELECT "public"."user_characters"."character_id",
"public"."user_characters"."user_id",
"public"."user_characters"."character_name",
"public"."user_characters"."server_name",
"public"."user_characters"."is_main",
"public"."user_characters"."verified",
"public"."user_characters"."created_at"
FROM "public"."user_characters"
WHERE ("public"."user_characters"."user_id" = $1
AND "public"."user_characters"."server_name" = $2
AND "public"."user_characters"."is_main" = $3)
LIMIT $4 OFFSET $5
```
**O que é:**
- Busca o personagem principal do vendedor
- Usado para exibir o nome do personagem nos cards de anúncio
**Por que aparece:**
- Para cada anúncio, busca o nome do personagem do vendedor
- Isso acontece porque implementamos a busca de `seller_character_name`
---
### 4. **SELECT de Servers** (Linha 66)
```sql
SELECT "public"."servers"."server_id",
"public"."servers"."server_name",
-- ... outros campos ...
FROM "public"."servers"
WHERE "public"."servers"."is_active" = $1
ORDER BY "public"."servers"."server_name" ASC
OFFSET $2
```
**O que é:**
- Busca todos os servidores ativos
- Usado para popular o filtro de servidores no marketplace
**Por que aparece:**
- O componente de filtros está carregando a lista de servidores
---
### 5. **BEGIN / COMMIT** (Linhas 23, 27, 57, 60, 92, 95)
```sql
BEGIN
-- ... queries ...
COMMIT
```
**O que é:**
- **Transações** do banco de dados
- Garante que múltiplas operações sejam executadas atomicamente
- Se uma falhar, todas são revertidas (ROLLBACK)
**Por que aparece:**
- Operações que precisam de atomicidade (ex: inserir múltiplos eventos de telemetria em batch)
---
## 🎯 Por que isso é útil?
### ✅ Benefícios:
1. **Debugging:**
- Ver exatamente quais queries estão sendo executadas
- Identificar queries N+1 (muitas queries desnecessárias)
- Verificar se os índices estão sendo usados
2. **Performance:**
- Identificar queries lentas
- Ver quantas queries estão sendo executadas
- Otimizar queries que são executadas repetidamente
3. **Entendimento:**
- Aprender como o Prisma traduz suas operações para SQL
- Ver como os relacionamentos são resolvidos
---
## ⚙️ Como Controlar os Logs
### Opção 1: Desabilitar completamente (não recomendado em dev)
```typescript
// lib/prisma/app.ts
export const prisma = new PrismaClient({
log: ["error"], // Apenas erros
});
```
### Opção 2: Apenas erros e warnings
```typescript
export const prisma = new PrismaClient({
log: ["error", "warn"],
});
```
### Opção 3: Logs mais detalhados (com informações de tempo)
```typescript
export const prisma = new PrismaClient({
log: [
{ level: "query", emit: "event" },
{ level: "error", emit: "stdout" },
{ level: "warn", emit: "stdout" },
],
});
```
### Opção 4: Manter como está (recomendado)
```typescript
// Atual (já está assim)
export const prisma = new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"] // ← Bom para desenvolvimento
: ["error"], // ← Otimizado para produção
});
```
**✅ Recomendação:** Manter como está! Os logs são úteis em desenvolvimento e não aparecem em produção.
---
## 🔍 Análise de Performance dos Seus Logs
### Queries que aparecem frequentemente:
1. **SELECT listings** (2 queries):
- Uma para contar total
- Uma para buscar os itens
- **Status:** ✅ Normal (paginação requer ambas)
2. **SELECT user_characters** (1 por anúncio):
- Busca o personagem do vendedor
- **Status:** ⚠️ Potencial otimização
- **Sugestão:** Já está otimizado com `Map` para evitar queries duplicadas
3. **INSERT telemetry_events** (em batch):
- Múltiplos eventos inseridos em uma transação
- **Status:** ✅ Otimizado (usa transação)
---
## 📊 Níveis de Log do Prisma
O Prisma suporta diferentes níveis de log:
```typescript
log: [
"query", // Todas as queries SQL
"info", // Informações gerais
"warn", // Avisos
"error", // Erros
]
```
**Ou com eventos:**
```typescript
log: [
{ level: "query", emit: "event" }, // Evento customizado
{ level: "error", emit: "stdout" }, // Console padrão
]
```
---
## 🎯 Conclusão
**Os logs `prisma:query` são:**
- ✅ **Normais** - Esperados em desenvolvimento
- ✅ **Úteis** - Ajudam a debugar e otimizar
- ✅ **Automáticos** - Não afetam performance
- ✅ **Desabilitados em produção** - Não aparecem em produção
**Você deve:**
- ✅ **Manter** os logs em desenvolvimento
- ✅ **Usar** para identificar problemas de performance
- ✅ **Ignorar** se não estiver debuggando (são apenas informativos)
---
## 🔗 Referências
- [Prisma Logging Documentation](https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/logging)
- [Prisma Query Performance](https://www.prisma.io/docs/guides/performance-and-optimization/query-optimization-performance)
+3
View File
@@ -377,3 +377,6 @@ python main_selenium.py --url "https://www.tibiawiki.com.br/wiki/Espadas"
E aproveite a velocidade!
@@ -0,0 +1,96 @@
/**
* Background Jobs para Telemetria
* Agregação de métricas e expiração de anúncios
*/
import { aggregateHourlyMetrics } from "@/lib/db/queries/telemetry";
import { query } from "@/lib/db/index";
import { secureLogger } from "@/lib/secure-logger";
/**
* Agrega eventos de telemetria em métricas
* Executar a cada 1 hora via cron ou scheduler
*/
export async function aggregateTelemetry(): Promise<void> {
try {
secureLogger.info("🔄 Iniciando agregação de telemetria...");
await aggregateHourlyMetrics();
secureLogger.info("✅ Agregação de telemetria concluída");
} catch (error) {
secureLogger.error("❌ Erro ao agregar telemetria", error);
throw error;
}
}
/**
* Expira anúncios antigos (30 dias)
* Executar diariamente
*/
export async function expireOldListings(): Promise<void> {
try {
secureLogger.info("🔄 Iniciando expiração de anúncios antigos...");
const result = await query(
`
UPDATE listings
SET listing_status = 'expired',
updated_at = CURRENT_TIMESTAMP
WHERE listing_status = 'active'
AND (
expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP
OR
(expires_at IS NULL AND created_at < CURRENT_TIMESTAMP - INTERVAL '30 days')
)
RETURNING listing_id
`,
[]
);
const expiredCount = result.rowCount || 0;
secureLogger.info(`✅ ${expiredCount} anúncios expirados`);
} catch (error) {
secureLogger.error("❌ Erro ao expirar anúncios", error);
throw error;
}
}
/**
* Atualiza histórico de preços quando mudar
* (Já implementado via trigger, mas pode ser chamado manualmente)
*/
export async function updatePriceHistory(): Promise<void> {
try {
secureLogger.info("🔄 Verificando histórico de preços...");
// O trigger já faz isso automaticamente, mas podemos adicionar lógica adicional aqui
// Por exemplo, verificar se há inconsistências ou limpar históricos muito antigos
secureLogger.info("✅ Histórico de preços verificado");
} catch (error) {
secureLogger.error("❌ Erro ao atualizar histórico de preços", error);
throw error;
}
}
/**
* Executa todos os background jobs
*/
export async function runBackgroundJobs(): Promise<void> {
try {
secureLogger.info("🚀 Iniciando background jobs...");
await Promise.all([
aggregateTelemetry(),
expireOldListings(),
updatePriceHistory(),
]);
secureLogger.info("✅ Todos os background jobs concluídos");
} catch (error) {
secureLogger.error("❌ Erro ao executar background jobs", error);
throw error;
}
}
+28 -5
View File
@@ -74,11 +74,34 @@ export async function query<T = any>(
}
return result;
} catch (error) {
console.error("❌ Erro ao executar query:", {
query: text.substring(0, 100),
error: error instanceof Error ? error.message : String(error),
});
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorCode = error?.code;
const errorStack = error instanceof Error ? error.stack : undefined;
// Ignorar silenciosamente erros relacionados à tabela "items" não existir
// Isso é esperado durante a transição e já está sendo tratado nas funções
const isItemsTableMissing =
errorCode === "42P01" &&
(errorMessage.includes('relation "items" does not exist') ||
text.includes('FROM items') ||
text.includes('FROM "items"'));
// Não logar erros esperados sobre tabela items não existir
if (!isItemsTableMissing) {
console.error("❌ Erro ao executar query:", {
query: text.substring(0, 200),
error: errorMessage,
stack: errorStack,
});
// Se o erro for sobre coluna não existir, sugerir executar migração
if (errorMessage.includes("column") && errorMessage.includes("does not exist")) {
console.error("\n💡 DICA: Parece que a migração do banco de dados não foi executada.");
console.error(" Execute: pnpm run db:migrate\n");
}
}
throw error;
}
}
@@ -0,0 +1,189 @@
-- Migration 012: Telemetria e Expansão de Anúncios
-- Criado em: 2025-01-27
-- ============================================
-- 1. Tabela de Eventos de Telemetria
-- ============================================
CREATE TABLE IF NOT EXISTS telemetry_events (
event_id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
ad_id INTEGER REFERENCES listings(listing_id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
session_id VARCHAR(255),
metadata JSONB DEFAULT '{}'::jsonb,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Índices para performance
CREATE INDEX IF NOT EXISTS idx_telemetry_ad_id ON telemetry_events(ad_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_user_id ON telemetry_events(user_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp ON telemetry_events(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_telemetry_event_type ON telemetry_events(event_type);
CREATE INDEX IF NOT EXISTS idx_telemetry_session_id ON telemetry_events(session_id);
-- Índice composto para queries de analytics
CREATE INDEX IF NOT EXISTS idx_telemetry_ad_type_time ON telemetry_events(ad_id, event_type, timestamp DESC);
-- ============================================
-- 2. Expansão da Tabela listings
-- ============================================
ALTER TABLE listings
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS favorites_count INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS inquiries_count INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS price_history JSONB DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP;
-- Índices para novos campos
CREATE INDEX IF NOT EXISTS idx_listings_is_featured ON listings(is_featured) WHERE is_featured = TRUE;
CREATE INDEX IF NOT EXISTS idx_listings_expires_at ON listings(expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_listings_favorites_count ON listings(favorites_count DESC);
-- ============================================
-- 3. Tabela de Analytics Agregados
-- ============================================
CREATE TABLE IF NOT EXISTS ad_analytics (
ad_id INTEGER PRIMARY KEY REFERENCES listings(listing_id) ON DELETE CASCADE,
impressions INTEGER DEFAULT 0,
clicks INTEGER DEFAULT 0,
ctr DECIMAL(5, 2) DEFAULT 0.00, -- Click-through rate em porcentagem
avg_view_duration DECIMAL(10, 2) DEFAULT 0.00, -- em segundos
bounce_rate DECIMAL(5, 2) DEFAULT 0.00, -- em porcentagem
inquiries INTEGER DEFAULT 0,
favorites INTEGER DEFAULT 0,
conversion_rate DECIMAL(5, 2) DEFAULT 0.00, -- em porcentagem
time_to_sell INTEGER DEFAULT NULL, -- em horas
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Trigger para atualizar last_updated automaticamente
CREATE OR REPLACE FUNCTION update_ad_analytics_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.last_updated = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_ad_analytics_updated_at
BEFORE UPDATE ON ad_analytics
FOR EACH ROW
EXECUTE FUNCTION update_ad_analytics_updated_at();
-- ============================================
-- 4. Tabela de Inventário do Usuário
-- ============================================
CREATE TABLE IF NOT EXISTS user_inventory (
inventory_id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
item_id INTEGER NOT NULL,
item_name VARCHAR(255) NOT NULL,
quantity INTEGER DEFAULT 1 CHECK (quantity > 0),
acquired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, item_id)
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_user_inventory_user_id ON user_inventory(user_id);
CREATE INDEX IF NOT EXISTS idx_user_inventory_item_id ON user_inventory(item_id);
-- ============================================
-- 5. Tabela de Favoritos
-- ============================================
CREATE TABLE IF NOT EXISTS user_favorites (
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
ad_id INTEGER NOT NULL REFERENCES listings(listing_id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, ad_id)
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_user_favorites_ad_id ON user_favorites(ad_id);
-- ============================================
-- 6. Trigger para Atualizar favorites_count
-- ============================================
CREATE OR REPLACE FUNCTION update_listing_favorites_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE listings
SET favorites_count = favorites_count + 1
WHERE listing_id = NEW.ad_id;
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
UPDATE listings
SET favorites_count = GREATEST(0, favorites_count - 1)
WHERE listing_id = OLD.ad_id;
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_listing_favorites_count
AFTER INSERT OR DELETE ON user_favorites
FOR EACH ROW
EXECUTE FUNCTION update_listing_favorites_count();
-- ============================================
-- 7. Função para Inicializar Analytics ao Criar Anúncio
-- ============================================
CREATE OR REPLACE FUNCTION initialize_ad_analytics()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO ad_analytics (ad_id)
VALUES (NEW.listing_id)
ON CONFLICT (ad_id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_initialize_ad_analytics
AFTER INSERT ON listings
FOR EACH ROW
EXECUTE FUNCTION initialize_ad_analytics();
-- ============================================
-- 8. Função para Atualizar Price History
-- ============================================
CREATE OR REPLACE FUNCTION update_price_history()
RETURNS TRIGGER AS $$
DECLARE
new_price_entry JSONB;
current_history JSONB;
BEGIN
-- Verifica se algum preço mudou
IF (OLD.price_pix IS DISTINCT FROM NEW.price_pix) OR
(OLD.price_tibia_coins IS DISTINCT FROM NEW.price_tibia_coins) OR
(OLD.price_gold_coins IS DISTINCT FROM NEW.price_gold_coins) THEN
-- Cria entrada de histórico
new_price_entry := jsonb_build_object(
'timestamp', CURRENT_TIMESTAMP,
'price_pix', NEW.price_pix,
'price_tibia_coins', NEW.price_tibia_coins,
'price_gold_coins', NEW.price_gold_coins
);
-- Obtém histórico atual
current_history := COALESCE(NEW.price_history, '[]'::jsonb);
-- Adiciona nova entrada no início do array
NEW.price_history := jsonb_insert(
current_history,
'{0}',
new_price_entry
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_price_history
BEFORE UPDATE ON listings
FOR EACH ROW
EXECUTE FUNCTION update_price_history();
@@ -0,0 +1,14 @@
-- Migration 013: Suporte a troca por item em anúncios
-- Criado em: 2025-01-29
-- Adicionar colunas para troca por item
ALTER TABLE listings
ADD COLUMN IF NOT EXISTS trade_item_id INTEGER,
ADD COLUMN IF NOT EXISTS trade_item_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS trade_item_quantity INTEGER DEFAULT 1 CHECK (trade_item_quantity > 0);
-- Comentários para documentação
COMMENT ON COLUMN listings.trade_item_id IS 'ID do item aceito em troca (referência ao banco de itens do TibiaWiki)';
COMMENT ON COLUMN listings.trade_item_name IS 'Nome do item aceito em troca';
COMMENT ON COLUMN listings.trade_item_quantity IS 'Quantidade do item aceito em troca';
@@ -0,0 +1,54 @@
-- Migration 014: Adicionar campos de incremento mínimo e preços iniciais para leilões
-- Criado em: 2025-01-29
-- Adicionar colunas para incremento mínimo de lance
ALTER TABLE listings
ADD COLUMN IF NOT EXISTS min_bid_increment DECIMAL(10, 2);
-- Adicionar colunas para armazenar preços iniciais (para leilões)
ALTER TABLE listings
ADD COLUMN IF NOT EXISTS initial_price_pix DECIMAL(10, 2),
ADD COLUMN IF NOT EXISTS initial_price_tibia_coins INTEGER,
ADD COLUMN IF NOT EXISTS initial_price_gold_coins BIGINT;
-- Comentários para documentação
COMMENT ON COLUMN listings.min_bid_increment IS 'Incremento mínimo para novos lances (padrão: 5% do preço inicial ou valor fixo)';
COMMENT ON COLUMN listings.initial_price_pix IS 'Preço inicial em PIX (para leilões)';
COMMENT ON COLUMN listings.initial_price_tibia_coins IS 'Preço inicial em Tibia Coins (para leilões)';
COMMENT ON COLUMN listings.initial_price_gold_coins IS 'Preço inicial em Gold Coins (para leilões)';
-- Atualizar preços iniciais para leilões existentes (copiar dos preços atuais)
UPDATE listings
SET
initial_price_pix = price_pix,
initial_price_tibia_coins = price_tibia_coins,
initial_price_gold_coins = price_gold_coins
WHERE is_auction = TRUE
AND (initial_price_pix IS NULL OR initial_price_tibia_coins IS NULL OR initial_price_gold_coins IS NULL);
-- Calcular incremento mínimo para leilões existentes (5% do preço inicial ou mínimo de R$ 10,00)
UPDATE listings
SET min_bid_increment = GREATEST(
COALESCE(initial_price_pix * 0.05, 0),
10.00
)
WHERE is_auction = TRUE
AND min_bid_increment IS NULL
AND initial_price_pix IS NOT NULL;
-- Para leilões com preço em TC ou GP, usar incremento fixo de 5% ou mínimo
UPDATE listings
SET min_bid_increment = COALESCE(
min_bid_increment,
GREATEST(
CASE
WHEN initial_price_tibia_coins IS NOT NULL THEN initial_price_tibia_coins * 0.05
WHEN initial_price_gold_coins IS NOT NULL THEN initial_price_gold_coins * 0.05
ELSE 10.00
END,
10.00
)
)
WHERE is_auction = TRUE
AND min_bid_increment IS NULL;
@@ -0,0 +1,47 @@
-- Migration 015: Sistema de Intenções de Compra
-- Criado em: 2025-01-29
CREATE TABLE IF NOT EXISTS purchase_intentions (
intention_id SERIAL PRIMARY KEY,
listing_id INTEGER NOT NULL REFERENCES listings(listing_id) ON DELETE CASCADE,
buyer_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
payment_method VARCHAR(20) NOT NULL CHECK (payment_method IN ('pix', 'tibia_coins', 'gold_coins', 'trade')),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'expired', 'completed')),
buyer_message TEXT,
seller_response TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
accepted_at TIMESTAMP,
rejected_at TIMESTAMP
);
-- Índices para performance
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_listing_id ON purchase_intentions(listing_id);
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_buyer_id ON purchase_intentions(buyer_id);
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_status ON purchase_intentions(status);
CREATE INDEX IF NOT EXISTS idx_purchase_intentions_created_at ON purchase_intentions(created_at DESC);
-- Constraint: apenas uma intenção pendente por listing+buyer
CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_intentions_unique_pending
ON purchase_intentions(listing_id, buyer_id)
WHERE status = 'pending';
-- Trigger para atualizar updated_at
CREATE OR REPLACE FUNCTION update_purchase_intentions_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_purchase_intentions_updated_at
BEFORE UPDATE ON purchase_intentions
FOR EACH ROW
EXECUTE FUNCTION update_purchase_intentions_updated_at();
-- Comentários para documentação
COMMENT ON TABLE purchase_intentions IS 'Intenções de compra de anúncios - permite múltiplos compradores interessados';
COMMENT ON COLUMN purchase_intentions.status IS 'pending: aguardando resposta do vendedor, accepted: aceita, rejected: rejeitada, expired: expirada, completed: concluída';
@@ -0,0 +1,70 @@
-- Migration 016: Tabela Unificada de Itens
-- Criado em: 2025-01-29
-- Objetivo: Unificar todos os itens de todas as categorias do SQLite em uma única tabela
-- com IDs únicos e consistentes, permitindo JOINs confiáveis
CREATE TABLE IF NOT EXISTS items (
item_id SERIAL PRIMARY KEY,
-- ID original da tabela de origem (para referência)
source_table_id INTEGER NOT NULL,
-- Nome da tabela de origem (espadas, armaduras, etc)
source_table_name VARCHAR(50) NOT NULL,
-- Nome do item (normalizado)
item_name VARCHAR(255) NOT NULL,
-- Categoria (normalizada para inglês)
category VARCHAR(50) NOT NULL,
-- Caminho da imagem local (se houver)
image_local_path TEXT,
-- URL da imagem externa (se houver)
image_url TEXT,
-- Raridade
rarity VARCHAR(20),
-- Nível (se aplicável)
level INTEGER,
-- Peso (se aplicável)
weight DECIMAL(10, 2),
-- Slots (se aplicável)
slots INTEGER,
-- Skill para usar (se aplicável)
skill VARCHAR(50),
-- Dados adicionais em JSON (para campos extras que variam por categoria)
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Índices para performance
CONSTRAINT unique_source_item UNIQUE (source_table_name, source_table_id)
);
-- Índices para busca rápida
CREATE INDEX IF NOT EXISTS idx_items_name ON items(item_name);
CREATE INDEX IF NOT EXISTS idx_items_category ON items(category);
CREATE INDEX IF NOT EXISTS idx_items_rarity ON items(rarity);
CREATE INDEX IF NOT EXISTS idx_items_name_lower ON items(LOWER(item_name));
CREATE INDEX IF NOT EXISTS idx_items_category_name ON items(category, item_name);
-- Índice GIN para busca em JSONB
CREATE INDEX IF NOT EXISTS idx_items_metadata ON items USING GIN (metadata);
-- Trigger para atualizar updated_at
CREATE OR REPLACE FUNCTION update_items_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_items_updated_at
BEFORE UPDATE ON items
FOR EACH ROW
EXECUTE FUNCTION update_items_updated_at();
-- Comentários para documentação
COMMENT ON TABLE items IS 'Tabela unificada de itens - todos os itens de todas as categorias com IDs únicos';
COMMENT ON COLUMN items.source_table_id IS 'ID original do item na tabela de origem (SQLite)';
COMMENT ON COLUMN items.source_table_name IS 'Nome da tabela de origem (espadas, armaduras, etc)';
COMMENT ON COLUMN items.category IS 'Categoria normalizada (weapon, armor, helmet, etc)';
COMMENT ON COLUMN items.metadata IS 'Campos extras que variam por categoria (proteção, bonus, etc)';
@@ -0,0 +1,20 @@
-- Migration 017: Adicionar Foreign Key e Atualizar Listings
-- Criado em: 2025-01-29
-- Objetivo: Adicionar foreign key de listings para items e garantir integridade
-- Primeiro, adicionar coluna item_id_unified se não existir (para migração gradual)
-- Depois, podemos fazer migration dos dados existentes
-- Por enquanto, vamos manter item_id como está e adicionar uma coluna de referência
-- que será populada gradualmente
-- Adicionar foreign key para items (opcional, permite NULL para itens antigos durante migração)
ALTER TABLE listings
ADD COLUMN IF NOT EXISTS item_id_unified INTEGER REFERENCES items(item_id) ON DELETE SET NULL;
-- Índice para performance
CREATE INDEX IF NOT EXISTS idx_listings_item_id_unified ON listings(item_id_unified);
-- Comentário
COMMENT ON COLUMN listings.item_id_unified IS 'Referência ao item na tabela unificada items (substitui item_id no futuro)';
@@ -0,0 +1,19 @@
-- Migration 018: Atualizar Listings para usar item_id_unified como padrão
-- Criado em: 2025-01-29
-- Objetivo: Migrar dados existentes de item_id para item_id_unified baseado em correspondência
-- Primeiro, vamos popular item_id_unified baseado em correspondência de nome
-- Isso é uma migração gradual - mantemos item_id para compatibilidade
UPDATE listings l
SET item_id_unified = (
SELECT i.item_id
FROM items i
WHERE LOWER(i.item_name) = LOWER(l.item_name)
LIMIT 1
)
WHERE l.item_id_unified IS NULL;
-- Comentário
COMMENT ON COLUMN listings.item_id IS 'ID legado do SQLite - será substituído por item_id_unified';
+25 -19
View File
@@ -1,9 +1,9 @@
/**
* Script de execução de migrations
/**
* Script de execução de migrations
* Executa todas as migrations SQL em ordem
*/
// Carregar variáveis de ambiente
// Carregar variáveis de ambiente
import { config } from "dotenv";
import { resolve } from "path";
@@ -18,8 +18,7 @@ import { getPool, query, healthCheck } from "../index";
const MIGRATIONS_DIR = join(__dirname, ".");
// Lista de migrations em ordem
const MIGRATIONS = [
"001_users.sql",
const MIGRATIONS = [ "001_users.sql",
"002_servers.sql",
"003_fix_servers_location.sql",
"004_listings.sql",
@@ -30,21 +29,27 @@ const MIGRATIONS = [
"009_item_price_history.sql",
"010_notifications.sql",
"011_donations.sql",
];
"012_telemetry_and_ads_enhancement.sql",
"013_add_trade_item_support.sql",
"014_add_bid_increment.sql",
"015_add_purchase_intentions.sql",
"016_create_unified_items_table.sql",
"017_add_items_foreign_key.sql",
"018_migrate_listings_to_unified_items.sql",];
async function runMigrations() {
console.log("🚀 Iniciando execução de migrations...\n");
console.log("🚀 Iniciando execução de migrations...\n");
// Verificar conexão com banco
console.log("🔍 Verificando conexão com PostgreSQL...");
// Verificar conexão com banco
console.log("🔍 Verificando conexão com PostgreSQL...");
const isHealthy = await healthCheck();
if (!isHealthy) {
console.error("❌ Falha na conexão com PostgreSQL. Verifique a variável PG_DB.");
console.error("❌ Falha na conexão com PostgreSQL. Verifique a variável PG_DB.");
process.exit(1);
}
console.log("✅ Conexão estabelecida com sucesso\n");
console.log("✅ Conexão estabelecida com sucesso\n");
// Verificar tabela de controle de migrations (opcional)
try {
@@ -55,7 +60,7 @@ async function runMigrations() {
)
`);
} catch (error) {
console.error("❌ Erro ao criar tabela de controle:", error);
console.error("❌ Erro ao criar tabela de controle:", error);
process.exit(1);
}
@@ -65,18 +70,18 @@ async function runMigrations() {
const version = migrationFile.replace(".sql", "");
try {
// Verificar se já foi executada
// Verificar se já foi executada
const checkResult = await query<{ version: string }>(
"SELECT version FROM schema_migrations WHERE version = $1",
[version]
);
if (checkResult.rows.length > 0) {
console.log(`⏭️ Migration ${version} já executada, pulando...`);
console.log(`⏭️ Migration ${version} já executada, pulando...`);
continue;
}
console.log(`📝 Executando migration: ${migrationFile}...`);
console.log(`📝 Executando migration: ${migrationFile}...`);
// Ler arquivo SQL
const sql = readFileSync(migrationPath, "utf-8");
@@ -90,14 +95,14 @@ async function runMigrations() {
[version]
);
console.log(`✅ Migration ${version} executada com sucesso\n`);
console.log(`✅ Migration ${version} executada com sucesso\n`);
} catch (error) {
console.error(`❌ Erro ao executar migration ${version}:`, error);
console.error(`❌ Erro ao executar migration ${version}:`, error);
process.exit(1);
}
}
console.log("🎉 Todas as migrations foram executadas com sucesso!");
console.log("🎉 Todas as migrations foram executadas com sucesso!");
// Fechar pool
const pool = getPool();
@@ -107,7 +112,8 @@ async function runMigrations() {
// Executar
runMigrations().catch((error) => {
console.error("❌ Erro fatal:", error);
console.error("❌ Erro fatal:", error);
process.exit(1);
});
+21
View File
@@ -92,6 +92,27 @@ export async function getUserCharacters(userId: number): Promise<Character[]> {
return result.rows;
}
/**
* Busca personagem principal de um usuário em um servidor específico
* Retorna o personagem principal (is_main = true) ou o primeiro personagem do servidor
*/
export async function getMainCharacterByUserId(
userId: number,
serverName: string
): Promise<Character | null> {
const result = await query<Character>(
`
SELECT * FROM user_characters
WHERE user_id = $1 AND server_name = $2
ORDER BY is_main DESC, created_at ASC
LIMIT 1
`,
[userId, serverName]
);
return result.rows[0] || null;
}
/**
* Busca personagem por ID
*/
+138
View File
@@ -0,0 +1,138 @@
/**
* Queries de itens unificados
* Busca itens da tabela unificada items (PostgreSQL)
*/
import { query } from "../index";
export interface UnifiedItem {
item_id: number;
source_table_id: number;
source_table_name: string;
item_name: string;
category: string;
image_local_path: string | null;
image_url: string | null;
rarity: string | null;
level: number | null;
weight: number | null;
slots: number | null;
skill: string | null;
metadata: any; // JSONB
created_at: Date;
updated_at: Date;
}
/**
* Busca item por ID unificado
*/
export async function getItemByIdUnified(itemId: number): Promise<UnifiedItem | null> {
try {
const result = await query<UnifiedItem>(
`
SELECT
item_id,
source_table_id,
source_table_name,
item_name,
category,
image_local_path,
image_url,
rarity,
level,
weight,
slots,
skill,
metadata,
created_at,
updated_at
FROM items
WHERE item_id = $1
`,
[itemId]
);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
} catch (error: any) {
// Se a tabela não existe (código 42P01), retornar null silenciosamente
// Isso permite fallback para SQLite sem poluir logs
if (error?.code === "42P01") {
return null;
}
// Para outros erros, re-lançar
throw error;
}
}
/**
* Busca item por nome (case-insensitive)
*/
export async function getItemByNameUnified(itemName: string): Promise<UnifiedItem | null> {
try {
const result = await query<UnifiedItem>(
`
SELECT
item_id,
source_table_id,
source_table_name,
item_name,
category,
image_local_path,
image_url,
rarity,
level,
weight,
slots,
skill,
metadata,
created_at,
updated_at
FROM items
WHERE LOWER(item_name) = LOWER($1)
LIMIT 1
`,
[itemName]
);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
} catch (error: any) {
// Se a tabela não existe (código 42P01), retornar null silenciosamente
if (error?.code === "42P01") {
return null;
}
throw error;
}
}
/**
* Valida se um item existe na tabela unificada
*/
export async function itemExists(itemId: number): Promise<boolean> {
try {
const result = await query<{ exists: boolean }>(
`
SELECT EXISTS(
SELECT 1 FROM items WHERE item_id = $1
) as exists
`,
[itemId]
);
return result.rows[0]?.exists || false;
} catch (error: any) {
// Se a tabela não existe (código 42P01), retornar false silenciosamente
if (error?.code === "42P01") {
return false;
}
throw error;
}
}
+656 -62
View File
@@ -4,11 +4,12 @@
*/
import { query } from "../index";
import { itemExists } from "./items";
export interface Listing {
listing_id: number;
seller_id: number;
item_id: number;
item_id: number; // ID legado (SQLite) - mantido para compatibilidade
item_name: string;
quantity: number;
server_name: string;
@@ -25,11 +26,34 @@ export interface Listing {
updated_at: Date;
sold_at: Date | null;
views_count: number;
// Campos adicionados na migração 012
is_featured?: boolean;
favorites_count?: number;
inquiries_count?: number;
price_history?: any; // JSONB
expires_at?: Date | null;
// Campos adicionados na migração 013 (troca por item)
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
// Campos adicionados na migração 014 (leilões)
min_bid_increment?: number | null;
initial_price_pix?: number | null;
initial_price_tibia_coins?: number | null;
initial_price_gold_coins?: number | 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 calculado (não vem do banco diretamente)
seller_character_name?: string | null;
}
export interface CreateListingData {
seller_id: number;
item_id: number;
item_id?: number; // ID legado (SQLite) - opcional, mantido para compatibilidade
item_id_unified?: number; // ID unificado da tabela items - preferido
item_name: string;
quantity: number;
server_name: string;
@@ -39,6 +63,10 @@ export interface CreateListingData {
is_auction?: boolean;
auction_ends_at?: Date;
description?: string;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
min_bid_increment?: number;
}
export interface UpdateListingData {
@@ -71,30 +99,168 @@ export interface ListingFilters {
export async function createListing(
data: CreateListingData
): Promise<Listing> {
const result = await query<Listing>(
`
INSERT INTO listings (
seller_id, item_id, item_name, quantity, server_name,
price_pix, price_tibia_coins, price_gold_coins,
is_auction, auction_ends_at, description
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`,
[
data.seller_id,
data.item_id,
data.item_name,
data.quantity,
data.server_name,
data.price_pix || null,
data.price_tibia_coins || null,
data.price_gold_coins || null,
data.is_auction || false,
data.auction_ends_at || null,
data.description || null,
]
);
// Validar item_id_unified se fornecido
if (data.item_id_unified) {
const exists = await itemExists(data.item_id_unified);
if (!exists) {
throw new Error(`Item com ID unificado ${data.item_id_unified} não encontrado na tabela items`);
}
}
// Calcular incremento mínimo se for leilão e não foi fornecido
let minBidIncrement = data.min_bid_increment;
if (data.is_auction && !minBidIncrement) {
// 5% do preço inicial ou mínimo de R$ 10,00
const initialPrice = data.price_pix || 0;
minBidIncrement = Math.max(initialPrice * 0.05, 10.0);
}
// Usar item_id_unified se fornecido, senão usar item_id legado
const itemIdUnified = data.item_id_unified || null;
const itemIdLegacy = data.item_id || null;
let result;
try {
// Tentar INSERT com todas as colunas (se migração 014 foi executada)
result = await query<Listing>(
`
INSERT INTO listings (
seller_id, item_id, item_id_unified, item_name, quantity, server_name,
price_pix, price_tibia_coins, price_gold_coins,
is_auction, auction_ends_at, description,
trade_item_id, trade_item_name, trade_item_quantity,
initial_price_pix, initial_price_tibia_coins, initial_price_gold_coins,
min_bid_increment
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
RETURNING
listing_id, seller_id, item_id, item_name, quantity, server_name,
price_pix, price_tibia_coins, price_gold_coins,
is_auction, current_bid_amount, current_bidder_id, auction_ends_at,
description, listing_status, created_at, updated_at, sold_at, views_count,
COALESCE(is_featured, false) as is_featured,
COALESCE(favorites_count, 0) as favorites_count,
COALESCE(inquiries_count, 0) as inquiries_count,
COALESCE(price_history, '[]'::jsonb) as price_history,
expires_at,
trade_item_id, trade_item_name, trade_item_quantity,
min_bid_increment, initial_price_pix, initial_price_tibia_coins, initial_price_gold_coins
`,
[
data.seller_id,
itemIdLegacy,
itemIdUnified,
data.item_name,
data.quantity,
data.server_name,
data.price_pix || null,
data.price_tibia_coins || null,
data.price_gold_coins || null,
data.is_auction || false,
data.auction_ends_at || null,
data.description || null,
data.trade_item_id || null,
data.trade_item_name || null,
data.trade_item_quantity || null,
data.is_auction ? (data.price_pix || null) : null, // initial_price_pix
data.is_auction ? (data.price_tibia_coins || null) : null, // initial_price_tibia_coins
data.is_auction ? (data.price_gold_coins || null) : null, // initial_price_gold_coins
minBidIncrement || null,
]
);
} catch (error) {
// Se as colunas da migração 014 ou item_id_unified não existirem, usar INSERT sem elas
if (error instanceof Error && error.message.includes("does not exist")) {
try {
// Tentar com item_id_unified mas sem colunas de migração 014
result = await query<Listing>(
`
INSERT INTO listings (
seller_id, item_id, item_id_unified, item_name, quantity, server_name,
price_pix, price_tibia_coins, price_gold_coins,
is_auction, auction_ends_at, description,
trade_item_id, trade_item_name, trade_item_quantity
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING
listing_id, seller_id, item_id, item_name, quantity, server_name,
price_pix, price_tibia_coins, price_gold_coins,
is_auction, current_bid_amount, current_bidder_id, auction_ends_at,
description, listing_status, created_at, updated_at, sold_at, views_count,
COALESCE(is_featured, false) as is_featured,
COALESCE(favorites_count, 0) as favorites_count,
COALESCE(inquiries_count, 0) as inquiries_count,
COALESCE(price_history, '[]'::jsonb) as price_history,
expires_at,
trade_item_id, trade_item_name, trade_item_quantity
`,
[
data.seller_id,
itemIdLegacy,
itemIdUnified,
data.item_name,
data.quantity,
data.server_name,
data.price_pix || null,
data.price_tibia_coins || null,
data.price_gold_coins || null,
data.is_auction || false,
data.auction_ends_at || null,
data.description || null,
data.trade_item_id || null,
data.trade_item_name || null,
data.trade_item_quantity || null,
]
);
} catch (error2) {
// Se item_id_unified também não existir, usar apenas item_id legado
if (error2 instanceof Error && error2.message.includes("does not exist")) {
result = await query<Listing>(
`
INSERT INTO listings (
seller_id, item_id, item_name, quantity, server_name,
price_pix, price_tibia_coins, price_gold_coins,
is_auction, auction_ends_at, description,
trade_item_id, trade_item_name, trade_item_quantity
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING
listing_id, seller_id, item_id, item_name, quantity, server_name,
price_pix, price_tibia_coins, price_gold_coins,
is_auction, current_bid_amount, current_bidder_id, auction_ends_at,
description, listing_status, created_at, updated_at, sold_at, views_count,
COALESCE(is_featured, false) as is_featured,
COALESCE(favorites_count, 0) as favorites_count,
COALESCE(inquiries_count, 0) as inquiries_count,
COALESCE(price_history, '[]'::jsonb) as price_history,
expires_at,
trade_item_id, trade_item_name, trade_item_quantity
`,
[
data.seller_id,
itemIdLegacy,
data.item_name,
data.quantity,
data.server_name,
data.price_pix || null,
data.price_tibia_coins || null,
data.price_gold_coins || null,
data.is_auction || false,
data.auction_ends_at || null,
data.description || null,
data.trade_item_id || null,
data.trade_item_name || null,
data.trade_item_quantity || null,
]
);
} else {
throw error2;
}
}
} else {
throw error;
}
}
return result.rows[0];
}
@@ -103,14 +269,85 @@ export async function createListing(
* Busca anúncio por ID
*/
export async function getListingById(listingId: number): Promise<Listing | null> {
const result = await query<Listing>(
`
SELECT * FROM listings WHERE listing_id = $1
`,
[listingId]
);
let result;
try {
// Tentar query com todas as colunas (se migração 014 foi executada)
result = await query<Listing & { seller_character_name: string }>(
`
SELECT
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
i.item_id as item_id_unified,
i.image_local_path,
i.image_url,
i.category as item_category,
uc.character_name as seller_character_name
FROM listings l
LEFT JOIN items i ON i.item_id = l.item_id_unified
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
AND uc.server_name = l.server_name
AND uc.is_main = TRUE
WHERE l.listing_id = $1
`,
[listingId]
);
} catch (error) {
// Se as colunas da migração 014 não existirem, usar query sem elas
if (error instanceof Error && error.message.includes("does not exist")) {
result = await query<Listing & { seller_character_name: string }>(
`
SELECT
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
NULL::numeric as min_bid_increment,
NULL::numeric as initial_price_pix,
NULL::integer as initial_price_tibia_coins,
NULL::bigint as initial_price_gold_coins,
i.item_id as item_id_unified,
i.image_local_path,
i.image_url,
i.category as item_category,
uc.character_name as seller_character_name
FROM listings l
LEFT JOIN items i ON i.item_id = l.item_id_unified
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
AND uc.server_name = l.server_name
AND uc.is_main = TRUE
WHERE l.listing_id = $1
`,
[listingId]
);
} else {
throw error;
}
}
return result.rows[0] || null;
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
...row,
seller_character_name: row.seller_character_name || null,
};
}
/**
@@ -137,69 +374,153 @@ export async function getListings(
let paramIndex = 1;
if (status) {
conditions.push(`listing_status = $${paramIndex}`);
conditions.push(`l.listing_status = $${paramIndex}`);
params.push(status);
paramIndex++;
}
if (server_name) {
conditions.push(`server_name = $${paramIndex}`);
conditions.push(`l.server_name = $${paramIndex}`);
params.push(server_name);
paramIndex++;
}
if (item_id) {
conditions.push(`item_id = $${paramIndex}`);
conditions.push(`l.item_id = $${paramIndex}`);
params.push(item_id);
paramIndex++;
}
if (seller_id) {
conditions.push(`seller_id = $${paramIndex}`);
conditions.push(`l.seller_id = $${paramIndex}`);
params.push(seller_id);
paramIndex++;
}
if (is_auction !== undefined) {
conditions.push(`is_auction = $${paramIndex}`);
conditions.push(`l.is_auction = $${paramIndex}`);
params.push(is_auction);
paramIndex++;
}
if (search) {
conditions.push(`(item_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
conditions.push(`(l.item_name ILIKE $${paramIndex} OR l.description ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "WHERE 1=1";
// Contar total
const countResult = await query<{ count: string }>(
`SELECT COUNT(*) as count FROM listings ${whereClause}`,
`SELECT COUNT(*) as count FROM listings l ${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count, 10);
// Validar sortBy
// Validar sortBy - garantir que seja uma coluna válida
const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at";
const sortDirection = sortOrder === "ASC" ? "ASC" : "DESC";
// Mapear colunas para usar alias 'l.' quando necessário
const sortColumnWithAlias = `l.${sortColumn}`;
// Buscar listings
const offset = (page - 1) * pageSize;
const result = await query<Listing>(
`
SELECT * FROM listings
${whereClause}
ORDER BY ${sortColumn} ${sortDirection}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`,
[...params, pageSize, offset]
);
// Incrementar paramIndex para os parâmetros de LIMIT e OFFSET
const limitParamIndex = paramIndex;
const offsetParamIndex = paramIndex + 1;
const finalParams = [...params, pageSize, offset];
// Tentar query com todas as colunas (se migração 014 foi executada)
// Se falhar, usar query sem as novas colunas
let result;
try {
const querySql = `
SELECT
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
i.item_id as item_id_unified,
i.image_local_path,
i.image_url,
i.category as item_category,
uc.character_name as seller_character_name
FROM listings l
LEFT JOIN items i ON i.item_id = l.item_id_unified
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
AND uc.server_name = l.server_name
AND uc.is_main = TRUE
${whereClause}
ORDER BY ${sortColumnWithAlias} ${sortDirection}
LIMIT $${limitParamIndex} OFFSET $${offsetParamIndex}
`;
result = await query<Listing & { seller_character_name: string }>(
querySql,
finalParams
);
} catch (error) {
// Se as colunas da migração 014 não existirem, usar query sem elas
if (error instanceof Error && error.message.includes("does not exist")) {
const fallbackQuerySql = `
SELECT
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
NULL::numeric as min_bid_increment,
NULL::numeric as initial_price_pix,
NULL::integer as initial_price_tibia_coins,
NULL::bigint as initial_price_gold_coins,
i.item_id as item_id_unified,
i.image_local_path,
i.image_url,
i.category as item_category,
uc.character_name as seller_character_name
FROM listings l
LEFT JOIN items i ON i.item_id = l.item_id_unified
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
AND uc.server_name = l.server_name
AND uc.is_main = TRUE
${whereClause}
ORDER BY ${sortColumnWithAlias} ${sortDirection}
LIMIT $${limitParamIndex} OFFSET $${offsetParamIndex}
`;
result = await query<Listing & { seller_character_name: string }>(
fallbackQuerySql,
finalParams
);
} else {
throw error;
}
}
// Mapear resultados para garantir que seller_character_name seja null se não existir
const listings = result.rows.map(row => ({
...row,
seller_character_name: row.seller_character_name || null,
}));
return {
listings: result.rows,
listings,
total,
};
}
@@ -268,17 +589,147 @@ export async function updateListing(
}
params.push(listingId);
const result = await query<Listing>(
`
UPDATE listings
SET ${updates.join(", ")}
WHERE listing_id = $${paramIndex}
RETURNING *
`,
params
);
let result;
try {
// Tentar query com todas as colunas (se migração 014 foi executada)
result = await query<Listing & { seller_character_name: string }>(
`
UPDATE listings l
SET ${updates.join(", ")}
WHERE l.listing_id = $${paramIndex}
RETURNING
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins
`,
params
);
// Buscar dados do JOIN após o UPDATE
if (result.rows.length > 0) {
const listingId = result.rows[0].listing_id;
const joinResult = await query<Listing & { seller_character_name: string }>(
`
SELECT
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
i.item_id as item_id_unified,
i.image_local_path,
i.image_url,
i.category as item_category,
uc.character_name as seller_character_name
FROM listings l
LEFT JOIN items i ON i.item_id = l.item_id_unified
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
AND uc.server_name = l.server_name
AND uc.is_main = TRUE
WHERE l.listing_id = $1
`,
[listingId]
);
if (joinResult.rows.length > 0) {
result.rows[0] = joinResult.rows[0];
}
}
} catch (error) {
// Se as colunas da migração 014 não existirem, usar query sem elas
if (error instanceof Error && error.message.includes("does not exist")) {
result = await query<Listing & { seller_character_name: string }>(
`
UPDATE listings l
SET ${updates.join(", ")}
WHERE l.listing_id = $${paramIndex}
RETURNING
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
NULL::numeric as min_bid_increment,
NULL::numeric as initial_price_pix,
NULL::integer as initial_price_tibia_coins,
NULL::bigint as initial_price_gold_coins
`,
params
);
// Buscar dados do JOIN após o UPDATE
if (result.rows.length > 0) {
const listingId = result.rows[0].listing_id;
const joinResult = await query<Listing & { seller_character_name: string }>(
`
SELECT
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
NULL::numeric as min_bid_increment,
NULL::numeric as initial_price_pix,
NULL::integer as initial_price_tibia_coins,
NULL::bigint as initial_price_gold_coins,
i.item_id as item_id_unified,
i.image_local_path,
i.image_url,
i.category as item_category,
uc.character_name as seller_character_name
FROM listings l
LEFT JOIN items i ON i.item_id = l.item_id_unified
LEFT JOIN user_characters uc ON uc.user_id = l.seller_id
AND uc.server_name = l.server_name
AND uc.is_main = TRUE
WHERE l.listing_id = $1
`,
[listingId]
);
if (joinResult.rows.length > 0) {
result.rows[0] = joinResult.rows[0];
}
}
} else {
throw error;
}
}
return result.rows[0] || null;
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
...row,
seller_character_name: row.seller_character_name || null,
};
}
/**
@@ -336,3 +787,146 @@ export async function getActiveListings(
});
}
/**
* Coloca um lance em um leilão
* Valida se o lance é maior que o lance atual + incremento mínimo
*/
export async function placeBid(
listingId: number,
bidderId: number,
bidAmount: number,
paymentMethod: "pix" | "tc" | "gp"
): Promise<{ success: boolean; listing: Listing | null; previousBidderId: number | null; error?: string }> {
const { getPool } = await import("../index");
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// Buscar listing atual com JOIN
const listingResult = await client.query<Listing>(
`
SELECT
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
i.item_id as item_id_unified,
i.image_local_path,
i.image_url,
i.category as item_category
FROM listings l
LEFT JOIN items i ON i.item_id = l.item_id_unified
WHERE l.listing_id = $1
`,
[listingId]
);
if (listingResult.rows.length === 0) {
await client.query("ROLLBACK");
return { success: false, listing: null, previousBidderId: null, error: "Anúncio não encontrado" };
}
const listing = listingResult.rows[0];
// Validações
if (listing.listing_status !== "active") {
await client.query("ROLLBACK");
return { success: false, listing: null, previousBidderId: null, error: "Anúncio não está ativo" };
}
if (!listing.is_auction) {
await client.query("ROLLBACK");
return { success: false, listing: null, previousBidderId: null, error: "Este anúncio não é um leilão" };
}
if (listing.seller_id === bidderId) {
await client.query("ROLLBACK");
return { success: false, listing: null, previousBidderId: null, error: "Você não pode dar lance no seu próprio anúncio" };
}
if (listing.auction_ends_at && new Date(listing.auction_ends_at) < new Date()) {
await client.query("ROLLBACK");
return { success: false, listing: null, previousBidderId: null, error: "Leilão expirou" };
}
// Calcular lance mínimo
const currentBid = listing.current_bid_amount || 0;
const minIncrement = listing.min_bid_increment || 10; // Padrão de R$ 10,00
const minBid = currentBid + minIncrement;
if (bidAmount < minBid) {
await client.query("ROLLBACK");
return {
success: false,
listing: null,
previousBidderId: listing.current_bidder_id,
error: `Lance mínimo é ${minBid.toFixed(2)} (lance atual: ${currentBid.toFixed(2)} + incremento: ${minIncrement.toFixed(2)})`,
};
}
// Guardar ID do lanceador anterior
const previousBidderId = listing.current_bidder_id;
// Atualizar listing com novo lance
await client.query(
`
UPDATE listings
SET
current_bid_amount = $1,
current_bidder_id = $2,
updated_at = CURRENT_TIMESTAMP
WHERE listing_id = $3
`,
[bidAmount, bidderId, listingId]
);
// Buscar listing atualizado com JOIN
const updateResult = await client.query<Listing>(
`
SELECT
l.listing_id, l.seller_id, l.item_id, l.item_name, l.quantity, l.server_name,
l.price_pix, l.price_tibia_coins, l.price_gold_coins,
l.is_auction, l.current_bid_amount, l.current_bidder_id, l.auction_ends_at,
l.description, l.listing_status, l.created_at, l.updated_at, l.sold_at, l.views_count,
COALESCE(l.is_featured, false) as is_featured,
COALESCE(l.favorites_count, 0) as favorites_count,
COALESCE(l.inquiries_count, 0) as inquiries_count,
COALESCE(l.price_history, '[]'::jsonb) as price_history,
l.expires_at,
l.trade_item_id, l.trade_item_name, l.trade_item_quantity,
l.min_bid_increment, l.initial_price_pix, l.initial_price_tibia_coins, l.initial_price_gold_coins,
i.item_id as item_id_unified,
i.image_local_path,
i.image_url,
i.category as item_category
FROM listings l
LEFT JOIN items i ON i.item_id = l.item_id_unified
WHERE l.listing_id = $1
`,
[listingId]
);
await client.query("COMMIT");
return {
success: true,
listing: updateResult.rows[0],
previousBidderId,
};
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Queries de notificações
* CRUD completo para a tabela notifications
*/
import { query } from "../index";
export interface Notification {
notification_id: number;
user_id: number;
notification_type: "bid" | "sale" | "purchase" | "escrow" | "dispute" | "system";
title: string;
message: string;
related_id: number | null;
is_read: boolean;
send_whatsapp: boolean;
whatsapp_sent: boolean;
created_at: Date;
}
export interface CreateNotificationData {
user_id: number;
notification_type: Notification["notification_type"];
title: string;
message: string;
related_id?: number | null;
send_whatsapp?: boolean;
}
/**
* Cria uma nova notificação
*/
export async function createNotification(
data: CreateNotificationData
): Promise<Notification> {
const result = await query<Notification>(
`
INSERT INTO notifications (user_id, notification_type, title, message, related_id, send_whatsapp)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`,
[
data.user_id,
data.notification_type,
data.title,
data.message,
data.related_id || null,
data.send_whatsapp || false,
]
);
return result.rows[0];
}
/**
* Busca notificações de um usuário
*/
export async function getUserNotifications(
userId: number,
filters: { is_read?: boolean; limit?: number } = {}
): Promise<Notification[]> {
const { is_read, limit = 50 } = filters;
const conditions: string[] = [`user_id = $1`];
const params: any[] = [userId];
let paramIndex = 2;
if (is_read !== undefined) {
conditions.push(`is_read = $${paramIndex}`);
params.push(is_read);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await query<Notification>(
`
SELECT * FROM notifications
${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex}
`,
[...params, limit]
);
return result.rows;
}
/**
* Conta notificações não lidas de um usuário
*/
export async function getUnreadCount(userId: number): Promise<number> {
const result = await query<{ count: string }>(
`
SELECT COUNT(*) as count FROM notifications
WHERE user_id = $1 AND is_read = FALSE
`,
[userId]
);
return parseInt(result.rows[0].count, 10);
}
/**
* Marca notificação como lida
*/
export async function markAsRead(notificationId: number, userId: number): Promise<boolean> {
const result = await query(
`
UPDATE notifications
SET is_read = TRUE
WHERE notification_id = $1 AND user_id = $2
`,
[notificationId, userId]
);
return result.rowCount > 0;
}
/**
* Marca todas as notificações de um usuário como lidas
*/
export async function markAllAsRead(userId: number): Promise<number> {
const result = await query(
`
UPDATE notifications
SET is_read = TRUE
WHERE user_id = $1 AND is_read = FALSE
`,
[userId]
);
return result.rowCount || 0;
}
/**
* Remove notificação
*/
export async function deleteNotification(notificationId: number, userId: number): Promise<boolean> {
const result = await query(
`
DELETE FROM notifications
WHERE notification_id = $1 AND user_id = $2
`,
[notificationId, userId]
);
return result.rowCount > 0;
}
+656
View File
@@ -0,0 +1,656 @@
/**
* Queries de Telemetria
* Sistema completo de tracking e analytics
*/
import { query } from "../index";
export type TelemetryEventType =
| "ad_view"
| "ad_click"
| "ad_exit"
| "search"
| "filter"
| "favorite"
| "unfavorite"
| "purchase"
| "inquiry"
| "page_view"
| "price_change";
export interface TelemetryEvent {
event_id: number;
event_type: TelemetryEventType;
ad_id: number | null;
user_id: number | null;
session_id: string | null;
metadata: Record<string, any>;
timestamp: Date;
}
export interface TrackEventData {
event_type: TelemetryEventType;
ad_id?: number;
user_id?: number;
session_id?: string;
metadata?: Record<string, any>;
}
export 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;
}
export interface UserBehavior {
user_id: number;
search_queries: string[];
filters_applied: Record<string, any>;
items_viewed: Array<{
item_id: number;
duration: number;
timestamp: Date;
}>;
items_favorited: number[];
purchases: Array<{
ad_id: number;
price: number;
timestamp: Date;
}>;
active_hours: Array<{
hour: number;
activity_count: number;
}>;
}
export interface MarketplaceStats {
total_transactions: number;
avg_price_by_category: Record<string, number>;
conversion_rate: number;
most_searched_items: Array<{
item_id: number;
item_name: string;
search_count: number;
}>;
most_sold_items: Array<{
item_id: number;
item_name: string;
sales_count: number;
}>;
peak_hours: Array<{
hour: number;
activity_count: number;
}>;
avg_time_to_sell: number;
}
/**
* Registra um evento de telemetria
*/
export async function trackEvent(
data: TrackEventData
): Promise<TelemetryEvent> {
const result = await query<TelemetryEvent>(
`
INSERT INTO telemetry_events (
event_type, ad_id, user_id, session_id, metadata
)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`,
[
data.event_type,
data.ad_id || null,
data.user_id || null,
data.session_id || null,
JSON.stringify(data.metadata || {}),
]
);
return result.rows[0];
}
/**
* Registra múltiplos eventos em batch
*/
export async function trackEventsBatch(
events: TrackEventData[]
): Promise<TelemetryEvent[]> {
if (events.length === 0) return [];
const values: any[] = [];
const placeholders: string[] = [];
let paramIndex = 1;
for (const event of events) {
placeholders.push(
`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2}, $${paramIndex + 3}, $${paramIndex + 4})`
);
values.push(
event.event_type,
event.ad_id || null,
event.user_id || null,
event.session_id || null,
JSON.stringify(event.metadata || {})
);
paramIndex += 5;
}
const result = await query<TelemetryEvent>(
`
INSERT INTO telemetry_events (
event_type, ad_id, user_id, session_id, metadata
)
VALUES ${placeholders.join(", ")}
RETURNING *
`,
values
);
return result.rows;
}
/**
* Busca analytics de um anúncio específico
*/
export async function getAdAnalytics(adId: number): Promise<AdAnalytics | null> {
const result = await query<AdAnalytics>(
`
SELECT * FROM ad_analytics
WHERE ad_id = $1
`,
[adId]
);
return result.rows[0] || null;
}
/**
* Busca comportamento do usuário (comprador)
*/
export async function getUserBehavior(
userId: number,
startDate?: Date,
endDate?: Date
): Promise<UserBehavior | null> {
// Verificar se usuário existe
const userCheck = await query<{ user_id: number }>(
"SELECT user_id FROM users WHERE user_id = $1",
[userId]
);
if (userCheck.rows.length === 0) {
return null;
}
const dateFilter = startDate && endDate
? "AND timestamp BETWEEN $2 AND $3"
: "";
const params = [userId];
if (startDate && endDate) {
params.push(startDate, endDate);
}
// Buscar queries de busca
const searchQueries = await query<{ metadata: { query?: string } }>(
`
SELECT DISTINCT metadata->>'query' as query
FROM telemetry_events
WHERE user_id = $1 AND event_type = 'search' ${dateFilter}
`,
params
);
// Buscar filtros aplicados
const filters = await query<{ metadata: Record<string, any> }>(
`
SELECT DISTINCT metadata
FROM telemetry_events
WHERE user_id = $1 AND event_type = 'filter' ${dateFilter}
`,
params
);
// Buscar itens visualizados
const itemsViewed = await query<{
ad_id: number;
metadata: { duration?: number };
timestamp: Date;
}>(
`
SELECT ad_id, metadata, timestamp
FROM telemetry_events
WHERE user_id = $1 AND event_type = 'ad_view' AND ad_id IS NOT NULL ${dateFilter}
ORDER BY timestamp DESC
`,
params
);
// Buscar favoritos
const favorites = await query<{ ad_id: number }>(
`
SELECT DISTINCT ad_id
FROM telemetry_events
WHERE user_id = $1 AND event_type = 'favorite' AND ad_id IS NOT NULL ${dateFilter}
`,
params
);
// Buscar compras
const purchases = await query<{
ad_id: number;
metadata: { price?: number };
timestamp: Date;
}>(
`
SELECT ad_id, metadata, timestamp
FROM telemetry_events
WHERE user_id = $1 AND event_type = 'purchase' AND ad_id IS NOT NULL ${dateFilter}
ORDER BY timestamp DESC
`,
params
);
// Buscar horas ativas
const activeHours = await query<{
hour: number;
count: string;
}>(
`
SELECT EXTRACT(HOUR FROM timestamp)::INTEGER as hour, COUNT(*)::TEXT as count
FROM telemetry_events
WHERE user_id = $1 ${dateFilter}
GROUP BY hour
ORDER BY hour
`,
params
);
// Buscar item_id dos anúncios visualizados
const itemIds = await query<{ item_id: number }>(
`
SELECT DISTINCT l.item_id
FROM telemetry_events te
JOIN listings l ON l.listing_id = te.ad_id
WHERE te.user_id = $1 AND te.event_type = 'ad_view' AND te.ad_id IS NOT NULL ${dateFilter}
`,
params
);
return {
user_id: userId,
search_queries: searchQueries.rows
.map((r) => r.metadata?.query)
.filter((q): q is string => !!q),
filters_applied: filters.rows.reduce((acc, r) => {
return { ...acc, ...r.metadata };
}, {}),
items_viewed: itemsViewed.rows.map((r) => ({
item_id: r.ad_id,
duration: r.metadata?.duration || 0,
timestamp: r.timestamp,
})),
items_favorited: favorites.rows.map((r) => r.ad_id),
purchases: purchases.rows.map((r) => ({
ad_id: r.ad_id,
price: r.metadata?.price || 0,
timestamp: r.timestamp,
})),
active_hours: activeHours.rows.map((r) => ({
hour: r.hour,
activity_count: parseInt(r.count, 10),
})),
};
}
/**
* Busca métricas agregadas do marketplace
*/
export async function getMarketplaceStats(
startDate?: Date,
endDate?: Date
): Promise<MarketplaceStats> {
const dateFilter = startDate && endDate
? "WHERE timestamp BETWEEN $1 AND $2"
: "";
const params = startDate && endDate ? [startDate, endDate] : [];
// Total de transações
const transactions = await query<{ count: string }>(
`
SELECT COUNT(*)::TEXT as count
FROM telemetry_events
WHERE event_type = 'purchase' ${dateFilter.replace("timestamp", "timestamp")}
`,
params
);
// Preço médio por categoria (requer join com listings)
const avgPriceByCategory = await query<{
category: string;
avg_price: string;
}>(
`
SELECT
l.item_name,
AVG(COALESCE(l.price_pix, l.price_tibia_coins, l.price_gold_coins))::TEXT as avg_price
FROM listings l
JOIN telemetry_events te ON te.ad_id = l.listing_id
WHERE te.event_type = 'purchase' ${dateFilter.replace("timestamp", "te.timestamp")}
GROUP BY l.item_name
LIMIT 10
`,
params
);
// Taxa de conversão
const views = await query<{ count: string }>(
`
SELECT COUNT(*)::TEXT as count
FROM telemetry_events
WHERE event_type = 'ad_view' ${dateFilter}
`,
params
);
const purchases = await query<{ count: string }>(
`
SELECT COUNT(*)::TEXT as count
FROM telemetry_events
WHERE event_type = 'purchase' ${dateFilter}
`,
params
);
const viewsCount = parseInt(views.rows[0]?.count || "0", 10);
const purchasesCount = parseInt(purchases.rows[0]?.count || "0", 10);
const conversionRate = viewsCount > 0 ? (purchasesCount / viewsCount) * 100 : 0;
// Itens mais procurados
const mostSearched = await query<{
item_name: string;
search_count: string;
}>(
`
SELECT
metadata->>'query' as item_name,
COUNT(*)::TEXT as search_count
FROM telemetry_events
WHERE event_type = 'search' AND metadata->>'query' IS NOT NULL ${dateFilter}
GROUP BY metadata->>'query'
ORDER BY COUNT(*) DESC
LIMIT 10
`,
params
);
// Itens mais vendidos
const mostSold = await query<{
item_id: number;
item_name: string;
sales_count: string;
}>(
`
SELECT
l.item_id,
l.item_name,
COUNT(*)::TEXT as sales_count
FROM telemetry_events te
JOIN listings l ON l.listing_id = te.ad_id
WHERE te.event_type = 'purchase' ${dateFilter.replace("timestamp", "te.timestamp")}
GROUP BY l.item_id, l.item_name
ORDER BY COUNT(*) DESC
LIMIT 10
`,
params
);
// Horários de pico
const peakHours = await query<{
hour: number;
activity_count: string;
}>(
`
SELECT
EXTRACT(HOUR FROM timestamp)::INTEGER as hour,
COUNT(*)::TEXT as activity_count
FROM telemetry_events
${dateFilter}
GROUP BY hour
ORDER BY COUNT(*) DESC
LIMIT 10
`,
params
);
// Tempo médio para venda
const avgTimeToSell = await query<{
avg_hours: string;
}>(
`
SELECT AVG(aa.time_to_sell)::TEXT as avg_hours
FROM ad_analytics aa
WHERE aa.time_to_sell IS NOT NULL
`,
[]
);
return {
total_transactions: parseInt(transactions.rows[0]?.count || "0", 10),
avg_price_by_category: avgPriceByCategory.rows.reduce(
(acc, r) => {
acc[r.category] = parseFloat(r.avg_price);
return acc;
},
{} as Record<string, number>
),
conversion_rate: conversionRate,
most_searched_items: mostSearched.rows.map((r) => ({
item_id: 0, // Não temos item_id em searches, apenas query string
item_name: r.item_name || "",
search_count: parseInt(r.search_count, 10),
})),
most_sold_items: mostSold.rows.map((r) => ({
item_id: r.item_id,
item_name: r.item_name,
sales_count: parseInt(r.sales_count, 10),
})),
peak_hours: peakHours.rows.map((r) => ({
hour: r.hour,
activity_count: parseInt(r.activity_count, 10),
})),
avg_time_to_sell: parseFloat(avgTimeToSell.rows[0]?.avg_hours || "0"),
};
}
/**
* Agrega eventos de telemetria em métricas de analytics
* Executar periodicamente (ex: a cada hora)
*/
export async function aggregateHourlyMetrics(): Promise<void> {
// Agregar impressões (ad_view events)
await query(
`
INSERT INTO ad_analytics (ad_id, impressions, last_updated)
SELECT
ad_id,
COUNT(*) as impressions,
CURRENT_TIMESTAMP
FROM telemetry_events
WHERE event_type = 'ad_view'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
ON CONFLICT (ad_id) DO UPDATE SET
impressions = ad_analytics.impressions + EXCLUDED.impressions,
last_updated = CURRENT_TIMESTAMP
`
);
// Agregar cliques
await query(
`
UPDATE ad_analytics aa
SET clicks = clicks + sub.count,
last_updated = CURRENT_TIMESTAMP
FROM (
SELECT ad_id, COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'ad_click'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
) sub
WHERE aa.ad_id = sub.ad_id
`
);
// Atualizar CTR (Click-Through Rate)
await query(
`
UPDATE ad_analytics
SET ctr = CASE
WHEN impressions > 0 THEN (clicks::DECIMAL / impressions::DECIMAL) * 100
ELSE 0
END,
last_updated = CURRENT_TIMESTAMP
WHERE impressions > 0
`
);
// Agregar duração média de visualização
await query(
`
UPDATE ad_analytics aa
SET
avg_view_duration = COALESCE(
(
SELECT AVG((metadata->>'duration')::DECIMAL)
FROM telemetry_events
WHERE event_type = 'ad_exit'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
AND metadata->>'duration' IS NOT NULL
),
avg_view_duration
),
last_updated = CURRENT_TIMESTAMP
WHERE EXISTS (
SELECT 1 FROM telemetry_events
WHERE event_type = 'ad_exit'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
)
`
);
// Calcular bounce rate (views com duração < 3 segundos)
await query(
`
UPDATE ad_analytics aa
SET
bounce_rate = CASE
WHEN aa.impressions > 0 THEN
(
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
FROM telemetry_events
WHERE event_type = 'ad_exit'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
AND (metadata->>'duration')::DECIMAL < 3
)
ELSE 0
END,
last_updated = CURRENT_TIMESTAMP
WHERE aa.impressions > 0
`
);
// Agregar inquiries
await query(
`
UPDATE ad_analytics aa
SET inquiries = inquiries + sub.count,
last_updated = CURRENT_TIMESTAMP
FROM (
SELECT ad_id, COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'inquiry'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
) sub
WHERE aa.ad_id = sub.ad_id
`
);
// Agregar favoritos
await query(
`
UPDATE ad_analytics aa
SET favorites = favorites + sub.count,
last_updated = CURRENT_TIMESTAMP
FROM (
SELECT ad_id, COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'favorite'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
) sub
WHERE aa.ad_id = sub.ad_id
`
);
// Calcular taxa de conversão
await query(
`
UPDATE ad_analytics aa
SET
conversion_rate = CASE
WHEN aa.impressions > 0 THEN
(
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
FROM telemetry_events
WHERE event_type = 'purchase'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
)
ELSE 0
END,
last_updated = CURRENT_TIMESTAMP
WHERE EXISTS (
SELECT 1 FROM telemetry_events
WHERE event_type = 'purchase'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
)
`
);
// Calcular time_to_sell para anúncios vendidos
await query(
`
UPDATE ad_analytics aa
SET
time_to_sell = EXTRACT(EPOCH FROM (l.sold_at - l.created_at)) / 3600,
last_updated = CURRENT_TIMESTAMP
FROM listings l
WHERE aa.ad_id = l.listing_id
AND l.listing_status = 'sold'
AND l.sold_at IS NOT NULL
AND aa.time_to_sell IS NULL
AND l.sold_at >= NOW() - INTERVAL '1 hour'
`
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export interface Listing {
listing_id: number;
seller_id: number;
item_id: number;
item_name: string;
quantity: number;
server_name: string;
price_pix: number | string | null;
price_tibia_coins: number | string | null;
price_gold_coins: number | string | null;
is_auction: boolean;
description: string | null;
listing_status: string;
created_at: string;
views_count: number;
favorites_count: number;
is_featured?: boolean;
item_id_unified?: number | null;
image_local_path?: string | null;
image_url?: string | null;
item_category?: string | null;
seller_character_name?: string | null;
}
interface ListingsResponse {
listings: Listing[];
pagination?: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}
interface ListingsParams {
search?: string;
server_name?: string;
price_min?: string;
price_max?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
page?: number;
pageSize?: number;
}
/**
* Hook para buscar listings do marketplace
* Atualiza automaticamente a cada 10 segundos
*/
export function useListings(params: ListingsParams = {}) {
const queryParams = new URLSearchParams();
if (params.search) queryParams.set("search", params.search);
if (params.server_name) queryParams.set("server_name", params.server_name);
if (params.price_min) queryParams.set("price_min", params.price_min);
if (params.price_max) queryParams.set("price_max", params.price_max);
queryParams.set("sortBy", params.sortBy || "created_at");
queryParams.set("sortOrder", params.sortOrder || "DESC");
queryParams.set("page", String(params.page || 1));
queryParams.set("pageSize", String(params.pageSize || 20));
const { data, isLoading, error, refetch } = useQuery<ListingsResponse>({
queryKey: ["listings", params],
queryFn: async () => {
const res = await fetch(`/api/listings?${queryParams.toString()}`);
if (!res.ok) throw new Error("Erro ao buscar anúncios");
return res.json();
},
staleTime: 1000 * 10, // 10 segundos
refetchInterval: 1000 * 10, // Refetch a cada 10 segundos
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
return {
listings: data?.listings || [],
total: data?.pagination?.total || 0,
page: data?.pagination?.page || params.page || 1,
pageSize: data?.pagination?.pageSize || params.pageSize || 20,
totalPages: data?.pagination?.totalPages || 1,
isLoading,
error,
refetch,
};
}
/**
* Hook para buscar meus anúncios
* Atualiza automaticamente a cada 10 segundos
*/
export function useMyListings(status?: string) {
const queryParams = new URLSearchParams();
if (status) queryParams.set("status", status);
const { data, isLoading, error, refetch } = useQuery<ListingsResponse>({
queryKey: ["my-listings", status],
queryFn: async () => {
const res = await fetch(`/api/listings/my?${queryParams.toString()}`);
if (!res.ok) throw new Error("Erro ao buscar meus anúncios");
return res.json();
},
staleTime: 1000 * 10, // 10 segundos
refetchInterval: 1000 * 10, // Refetch a cada 10 segundos
refetchOnWindowFocus: true,
refetchOnReconnect: true,
enabled: true, // Sempre habilitado (autenticação é verificada no backend)
});
return {
listings: data?.listings || [],
isLoading,
error,
refetch,
};
}
/**
* Hook para invalidar cache de listings
*/
export function useInvalidateListings() {
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
};
return invalidate;
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useQuery } from "@tanstack/react-query";
export interface SessionUser {
userId: number;
username: string;
email: string;
}
interface SessionResponse {
success: boolean;
authenticated: boolean;
user?: SessionUser;
}
/**
* Hook para gerenciar sessão do usuário com React Query
* Atualiza automaticamente a cada 30 segundos
*/
export function useSession() {
const { data, isLoading, refetch } = useQuery<SessionResponse>({
queryKey: ["session"],
queryFn: async () => {
const response = await fetch("/api/auth/session", {
method: "GET",
credentials: "include",
});
return response.json();
},
staleTime: 1000 * 30, // 30 segundos
refetchInterval: 1000 * 30, // Refetch a cada 30 segundos
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const logout = async () => {
try {
await fetch("/api/auth/session", {
method: "DELETE",
credentials: "include",
});
// Invalidar cache e recarregar
window.location.href = "/";
} catch (error) {
console.error("Erro ao fazer logout:", error);
}
};
return {
user: data?.user || null,
isLoading,
isAuthenticated: data?.authenticated || false,
logout,
refreshSession: refetch,
};
}
+2 -102
View File
@@ -1,105 +1,5 @@
"use client";
import { useState, useEffect, useCallback } from "react";
export interface SessionUser {
userId: number;
username: string;
email: string;
}
interface SessionState {
user: SessionUser | null;
isLoading: boolean;
isAuthenticated: boolean;
}
/**
* Hook para gerenciar sessão do usuário
*/
export function useSession() {
const [session, setSession] = useState<SessionState>({
user: null,
isLoading: true,
isAuthenticated: false,
});
/**
* Verifica a sessão atual
*/
const checkSession = useCallback(async () => {
try {
const response = await fetch("/api/auth/session", {
method: "GET",
credentials: "include",
});
const data = await response.json();
if (data.success && data.authenticated) {
setSession({
user: data.user,
isLoading: false,
isAuthenticated: true,
});
} else {
setSession({
user: null,
isLoading: false,
isAuthenticated: false,
});
}
} catch (error) {
console.error("Erro ao verificar sessão:", error);
setSession({
user: null,
isLoading: false,
isAuthenticated: false,
});
}
}, []);
/**
* Encerra a sessão
*/
const logout = useCallback(async () => {
try {
await fetch("/api/auth/session", {
method: "DELETE",
credentials: "include",
});
setSession({
user: null,
isLoading: false,
isAuthenticated: false,
});
// Recarregar página para limpar estado
window.location.href = "/";
} catch (error) {
console.error("Erro ao fazer logout:", error);
}
}, []);
/**
* Atualiza a sessão após login
*/
const refreshSession = useCallback(async () => {
await checkSession();
}, [checkSession]);
// Verificar sessão ao montar componente
useEffect(() => {
checkSession();
}, [checkSession]);
return {
user: session.user,
isLoading: session.isLoading,
isAuthenticated: session.isAuthenticated,
logout,
refreshSession,
};
}
// Re-exportar o hook que usa React Query
export { useSession, type SessionUser } from "./use-session-query";
+100
View File
@@ -0,0 +1,100 @@
/**
* Hooks para tracking de telemetria
*/
import { useEffect, useRef, useCallback } from "react";
import { getTelemetry } from "../telemetry/MarketTelemetry";
import { useSession } from "./use-session";
/**
* Hook para tracking automático de visualização de anúncio
*/
export function useAdTracking(adId: number | null) {
const startTimeRef = useRef<number | null>(null);
const { user } = useSession();
useEffect(() => {
if (!adId) return;
const telemetry = getTelemetry(user?.userId);
startTimeRef.current = Date.now();
telemetry.trackAdView(adId);
return () => {
if (startTimeRef.current && adId) {
telemetry.trackAdExit(adId);
}
};
}, [adId, user?.userId]);
}
/**
* Hook para tracking global do marketplace
*/
export function useMarketTelemetry() {
const { user } = useSession();
const telemetryRef = useRef(getTelemetry(user?.userId));
// Atualizar user_id quando mudar
useEffect(() => {
if (user?.userId) {
telemetryRef.current.setUserId(user.userId);
}
}, [user?.userId]);
// Cleanup ao desmontar
useEffect(() => {
return () => {
telemetryRef.current.stop();
};
}, []);
const trackSearch = useCallback((query: string, filters?: Record<string, any>) => {
telemetryRef.current.trackSearch(query, filters);
}, []);
const trackFilter = useCallback((filters: Record<string, any>) => {
telemetryRef.current.trackFilterApplied(filters);
}, []);
const trackFavorite = useCallback((adId: number, action: "favorite" | "unfavorite") => {
telemetryRef.current.trackFavorite(adId, action);
}, []);
const trackPurchase = useCallback((adId: number, price: number) => {
telemetryRef.current.trackPurchase(adId, price);
}, []);
const trackInquiry = useCallback((adId: number) => {
telemetryRef.current.trackInquiry(adId);
}, []);
const trackPageView = useCallback((page: string) => {
telemetryRef.current.trackPageView(page);
}, []);
const trackAdClick = useCallback((adId: number, position?: number) => {
telemetryRef.current.trackAdClick(adId, position);
}, []);
const trackCustomEvent = useCallback((
eventType: string,
adId?: number,
metadata?: Record<string, any>
) => {
telemetryRef.current.trackCustomEvent(eventType, adId, metadata);
}, []);
return {
trackSearch,
trackFilter,
trackFavorite,
trackPurchase,
trackInquiry,
trackPageView,
trackAdClick,
trackCustomEvent,
telemetry: telemetryRef.current,
};
}
+48
View File
@@ -231,4 +231,52 @@ export function searchItems<T = any>(params: SearchParams): SearchResult<T> {
}
}
/**
* Busca um item por ID no banco de dados
* Busca em todas as tabelas de categorias até encontrar o item
*/
export function getItemById(itemId: number, itemName?: string): any | null {
const db = getDatabase();
if (!db) {
return null;
}
try {
// Buscar em todas as tabelas de categorias
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
for (const table of tables) {
try {
let item: any = null;
// Se itemName foi fornecido, buscar por ID E NOME (case-insensitive)
if (itemName) {
item = db.prepare(`SELECT * FROM ${table.name} WHERE id = ? AND LOWER(nome) = LOWER(?)`).get(itemId, itemName);
} else {
// Se não há nome, buscar apenas por ID (comportamento antigo)
item = db.prepare(`SELECT * FROM ${table.name} WHERE id = ?`).get(itemId);
}
if (item) {
return item;
}
} catch (err) {
// Ignorar erros de tabelas que não têm coluna 'id' ou 'nome'
continue;
}
}
return null;
} catch (error) {
console.error("[items-db] Erro ao buscar item por ID:", error);
return null;
} finally {
try {
db.close();
} catch {
// no-op
}
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Prisma Client para PostgreSQL (Banco Principal da Aplicação)
* Usa DATABASE_URL_APP do .env
*/
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+255
View File
@@ -0,0 +1,255 @@
/**
* Queries para banco SQLite de itens usando Prisma
* Como o banco tem tabelas dinâmicas (uma por categoria),
* usamos $queryRaw para queries dinâmicas
*/
import { itemsPrisma } from "./items";
import { enToPtCategoryMap } from "../item-images";
import { Prisma } from "@prisma/client";
export interface SearchParams {
q?: string;
categories?: string[];
rarities?: string[];
sort?:
| "name"
| "level-asc"
| "level-desc"
| "weight-asc"
| "weight-desc"
| "category";
vocation?: string;
page?: number;
pageSize?: number;
}
export interface SearchResult<T> {
items: T[];
total: number;
}
let schemaLogged = false;
// Tenta deduzir ordenação segura a partir do parâmetro de sort
function getOrderBy(sort: SearchParams["sort"]): string {
switch (sort) {
case "level-asc":
return "level ASC, name ASC";
case "level-desc":
return "level DESC, name ASC";
case "weight-asc":
return "weight ASC, name ASC";
case "weight-desc":
return "weight DESC, name ASC";
case "category":
return "category ASC, name ASC";
case "name":
default:
return "name ASC";
}
}
/**
* Busca itens no banco SQLite usando Prisma $queryRaw
*/
export async function searchItems<T = any>(
params: SearchParams
): Promise<SearchResult<T>> {
const {
q = "",
categories = [],
rarities = [],
sort = "name",
vocation,
page = 1,
pageSize = 24,
} = params;
const safePage = Math.max(1, page);
const safePageSize = Math.min(Math.max(1, pageSize), 50);
const offset = (safePage - 1) * safePageSize;
try {
// Log básico do schema na primeira chamada
if (!schemaLogged) {
try {
const tables = await itemsPrisma.$queryRaw<
Array<{ name: string }>
>`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`;
console.info("[items-db] Tabelas:", tables.map((t) => t.name));
schemaLogged = true;
} catch (intErr) {
console.warn("[items-db] Falha ao ler schema:", (intErr as any)?.message);
schemaLogged = true;
}
}
// Buscar todas as tabelas disponíveis
const allTables = await itemsPrisma.$queryRaw<
Array<{ name: string }>
>`SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence'`;
// Filtra por categorias solicitadas (EN → PT)
const allowedPt = new Set(
categories.length > 0
? categories.map((en) => enToPtCategoryMap[en] || en)
: allTables.map((t) => t.name)
);
const candidateTables = allTables
.map((t) => t.name)
.filter((name) => allowedPt.has(name));
const selects: string[] = [];
const bindings: any[] = [];
for (const tableName of candidateTables) {
try {
// Verificar colunas da tabela
const cols = await itemsPrisma.$queryRaw<
Array<{ name: string; type: string }>
>`PRAGMA table_info(${Prisma.raw(tableName)})`;
const colNames = new Set(cols.map((c) => (c?.name || "").toLowerCase()));
// Requer colunas mínimas
if (
!colNames.has("id") ||
!colNames.has("nome") ||
!colNames.has("image_url")
) {
continue;
}
// Se filtro de vocação foi solicitado e a tabela não possui coluna 'voc', ignora
if (vocation && !colNames.has("voc")) {
continue;
}
const tableWhere: string[] = [];
const tableBindings: any[] = [];
if (q && colNames.has("nome")) {
tableWhere.push("LOWER(nome) LIKE ?");
tableBindings.push(`%${q.toLowerCase()}%`);
}
if (vocation && colNames.has("voc")) {
const sel = vocation.toLowerCase();
tableWhere.push("REPLACE(LOWER(voc), ' ', '') = REPLACE(?, ' ', '')");
tableBindings.push(sel);
}
const whereSqlTbl =
tableWhere.length > 0 ? `WHERE ${tableWhere.join(" AND ")}` : "";
// Mapear colunas dinâmicas
const levelCol =
colNames.has("nivel") || colNames.has("lvl")
? `COALESCE(${colNames.has("nivel") ? "nivel" : "NULL"}, ${colNames.has("lvl") ? "lvl" : "NULL"})`
: "NULL";
const weightCol =
colNames.has("peso") || colNames.has("peso_em_oz")
? `COALESCE(${colNames.has("peso") ? "peso" : "NULL"}, ${colNames.has("peso_em_oz") ? "peso_em_oz" : "NULL"})`
: "NULL";
const slotsCol = colNames.has("slots") ? "slots" : "NULL";
const skillCol = colNames.has("skill_para_usar")
? "skill_para_usar"
: "NULL";
// Campos extras opcionais
const extraCols = [
"lvl",
"voc",
"volume",
"bonus_de_skill",
"protecao",
"dropa_de",
] as const;
const extraSelect = extraCols
.map((c) => (colNames.has(c) ? `${c}` : `NULL AS ${c}`))
.join(", ");
const selectSql = `SELECT
id,
nome AS name,
'${tableName}' AS category,
image_url AS image,
${levelCol} AS level,
${weightCol} AS weight,
${slotsCol} AS slots,
${skillCol} AS skill${extraSelect ? ", " + extraSelect : ""}
FROM ${tableName} ${whereSqlTbl}`;
selects.push(selectSql);
bindings.push(...tableBindings);
} catch (e) {
console.warn(`[items-db] Ignorando tabela ${tableName}:`, (e as any)?.message);
}
}
if (selects.length === 0) {
console.warn("[items-db] Nenhuma tabela compatível encontrada");
return { items: [], total: 0 } as SearchResult<T>;
}
const unionSql = selects.join(" UNION ALL ");
const orderBy = getOrderBy(sort);
// Contagem total
const countSql = `SELECT COUNT(1) as cnt FROM (${unionSql}) AS u`;
const countResult = await itemsPrisma.$queryRaw<
Array<{ cnt: number }>
>(Prisma.raw(countSql), ...bindings);
const total = countResult[0]?.cnt ?? 0;
// Buscar itens com paginação
const selectSql = `SELECT * FROM (${unionSql}) AS u ORDER BY ${orderBy} LIMIT ? OFFSET ?`;
const items = await itemsPrisma.$queryRaw<T>(
Prisma.raw(selectSql),
...bindings,
safePageSize,
offset
);
return { items, total };
} catch (error) {
console.error("[items-db] Erro ao buscar itens:", error);
return { items: [], total: 0 } as SearchResult<T>;
}
}
/**
* Busca um item por ID no banco de dados
* Busca em todas as tabelas de categorias até encontrar o item
*/
export async function getItemById(itemId: number): Promise<any | null> {
try {
// Buscar em todas as tabelas de categorias
const tables = await itemsPrisma.$queryRaw<
Array<{ name: string }>
>`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
for (const table of tables) {
try {
const item = await itemsPrisma.$queryRaw<any>(
Prisma.raw(`SELECT * FROM ${table.name} WHERE id = ?`),
itemId
);
if (item && item.length > 0) {
return item[0];
}
} catch (err) {
// Ignorar erros de tabelas que não têm coluna 'id' ou outros problemas
continue;
}
}
return null;
} catch (error) {
console.error("[items-db] Erro ao buscar item por ID:", error);
return null;
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Prisma Client para SQLite (Banco de Itens do TibiaWiki)
*
* NOTA: Por enquanto, mantemos better-sqlite3 para queries SQLite
* devido às limitações do Prisma com tabelas dinâmicas.
*
* Este arquivo está preparado para migração futura quando necessário.
*/
// Por enquanto, não exportamos nada - usando better-sqlite3 diretamente
// export const itemsPrisma = ... será implementado quando migrarmos SQLite
+94
View File
@@ -0,0 +1,94 @@
/**
* Queries de bids usando Prisma
* Migrado de lib/db/queries/bids.ts
*/
import { prisma } from "../app";
export interface Bid {
bid_id: number;
listing_id: number;
bidder_id: number;
bid_amount: number;
bid_type: "pix" | "tibia_coins" | "gold_coins" | null;
created_at: Date;
is_active: boolean;
}
export interface CreateBidData {
listing_id: number;
bidder_id: number;
bid_amount: number;
bid_type?: "pix" | "tibia_coins" | "gold_coins";
}
/**
* Cria um novo lance
*/
export async function createBid(data: CreateBidData): Promise<Bid> {
return await prisma.bid.create({
data: {
listing_id: data.listing_id,
bidder_id: data.bidder_id,
bid_amount: data.bid_amount,
bid_type: data.bid_type || null,
},
});
}
/**
* Busca lances de um anúncio
*/
export async function getBidsByListing(
listingId: number,
activeOnly: boolean = true
): Promise<Bid[]> {
return await prisma.bid.findMany({
where: {
listing_id: listingId,
...(activeOnly ? { is_active: true } : {}),
},
orderBy: [
{ bid_amount: "desc" },
{ created_at: "desc" },
],
});
}
/**
* Busca o maior lance de um anúncio
*/
export async function getHighestBid(listingId: number): Promise<Bid | null> {
return await prisma.bid.findFirst({
where: {
listing_id: listingId,
is_active: true,
},
orderBy: [
{ bid_amount: "desc" },
{ created_at: "desc" },
],
});
}
/**
* Cancela um lance (desativa)
*/
export async function cancelBid(bidId: number): Promise<boolean> {
const result = await prisma.bid.updateMany({
where: { bid_id: bidId },
data: { is_active: false },
});
return result.count > 0;
}
/**
* Busca lance por ID
*/
export async function getBidById(bidId: number): Promise<Bid | null> {
return await prisma.bid.findUnique({
where: { bid_id: bidId },
});
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Queries de personagens usando Prisma
* Migrado de lib/db/queries/characters.ts
*/
import { prisma } from "../app";
export interface Character {
character_id: number;
user_id: number;
character_name: string;
server_name: string;
is_main: boolean;
verified: boolean;
created_at: Date;
}
export interface CreateCharacterData {
character_name: string;
server_name: string;
is_main?: boolean;
}
export interface UpdateCharacterData {
character_name?: string;
server_name?: string;
is_main?: boolean;
}
/**
* Cria um novo personagem
* Se isMain=true, desmarca outros personagens como main
*/
export async function createCharacter(
userId: number,
data: CreateCharacterData
): Promise<Character> {
return await prisma.$transaction(async (tx) => {
// Se este personagem será main, desmarcar outros
if (data.is_main) {
await tx.userCharacter.updateMany({
where: { user_id: userId },
data: { is_main: false },
});
}
// Inserir novo personagem
return await tx.userCharacter.create({
data: {
user_id: userId,
character_name: data.character_name,
server_name: data.server_name,
is_main: data.is_main || false,
},
});
});
}
/**
* Lista todos os personagens de um usuário
*/
export async function getUserCharacters(userId: number): Promise<Character[]> {
return await prisma.userCharacter.findMany({
where: { user_id: userId },
orderBy: [
{ is_main: "desc" },
{ created_at: "asc" },
],
});
}
/**
* Busca personagem principal de um usuário em um servidor específico
*/
export async function getMainCharacterByUserId(
userId: number,
serverName: string
): Promise<Character | null> {
return await prisma.userCharacter.findFirst({
where: {
user_id: userId,
server_name: serverName,
},
orderBy: [
{ is_main: "desc" },
{ created_at: "asc" },
],
});
}
/**
* Busca personagem por ID
*/
export async function getCharacterById(
characterId: number
): Promise<Character | null> {
return await prisma.userCharacter.findUnique({
where: { character_id: characterId },
});
}
/**
* Atualiza personagem
* Verifica ownership e se isMain=true, desmarca outros
*/
export async function updateCharacter(
characterId: number,
userId: number,
data: UpdateCharacterData
): Promise<Character> {
// Verificar ownership primeiro
const existing = await getCharacterById(characterId);
if (!existing) {
throw new Error("Personagem não encontrado");
}
if (existing.user_id !== userId) {
throw new Error("Você não tem permissão para atualizar este personagem");
}
return await prisma.$transaction(async (tx) => {
// Se este personagem será main, desmarcar outros
if (data.is_main) {
await tx.userCharacter.updateMany({
where: {
user_id: userId,
character_id: { not: characterId },
},
data: { is_main: false },
});
}
// Atualizar personagem
const updateData: any = {};
if (data.character_name !== undefined) updateData.character_name = data.character_name;
if (data.server_name !== undefined) updateData.server_name = data.server_name;
if (data.is_main !== undefined) updateData.is_main = data.is_main;
if (Object.keys(updateData).length === 0) {
return existing;
}
return await tx.userCharacter.update({
where: { character_id: characterId },
data: updateData,
});
});
}
/**
* Remove personagem
* Verifica ownership antes de remover
*/
export async function deleteCharacter(
characterId: number,
userId: number
): Promise<void> {
// Verificar ownership
const existing = await getCharacterById(characterId);
if (!existing) {
throw new Error("Personagem não encontrado");
}
if (existing.user_id !== userId) {
throw new Error("Você não tem permissão para remover este personagem");
}
await prisma.userCharacter.delete({
where: { character_id: characterId },
});
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Queries de favoritos usando Prisma
*/
import { prisma } from "../app";
/**
* Verifica se um anúncio está favoritado por um usuário
*/
export async function isFavorite(userId: number, listingId: number): Promise<boolean> {
const favorite = await prisma.userFavorite.findUnique({
where: {
user_id_ad_id: {
user_id: userId,
ad_id: listingId,
},
},
});
return !!favorite;
}
/**
* Favorita um anúncio
*/
export async function addFavorite(userId: number, listingId: number): Promise<void> {
await prisma.userFavorite.upsert({
where: {
user_id_ad_id: {
user_id: userId,
ad_id: listingId,
},
},
create: {
user_id: userId,
ad_id: listingId,
},
update: {}, // Se já existe, não faz nada
});
}
/**
* Remove favorito de um anúncio
*/
export async function removeFavorite(userId: number, listingId: number): Promise<void> {
await prisma.userFavorite.delete({
where: {
user_id_ad_id: {
user_id: userId,
ad_id: listingId,
},
},
}).catch(() => {
// Ignorar erro se não existir (idempotente)
});
}
/**
* Toggle favorite (favorita se não estiver, desfavorita se estiver)
*/
export async function toggleFavorite(userId: number, listingId: number): Promise<boolean> {
const isFavorited = await isFavorite(userId, listingId);
if (isFavorited) {
await removeFavorite(userId, listingId);
return false;
} else {
await addFavorite(userId, listingId);
return true;
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Queries de itens usando Prisma
* Busca itens da tabela unificada items (PostgreSQL)
*/
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
export interface Item {
item_id: number;
source_table_id: number;
source_table_name: string;
item_name: string;
category: string;
image_local_path: string | null;
image_url: string | null;
rarity: string | null;
level: number | null;
weight: number | null;
slots: number | null;
skill: string | null;
metadata: any;
created_at: Date;
updated_at: Date;
}
export interface SearchItemsParams {
q?: string;
category?: string;
rarity?: string;
page?: number;
pageSize?: number;
}
/**
* Busca um item por ID unificado
*/
export async function getItemById(itemId: number): Promise<Item | null> {
const item = await prisma.item.findUnique({
where: { item_id: itemId },
});
if (!item) {
return null;
}
return {
...item,
weight: item.weight ? Number(item.weight) : null,
metadata: item.metadata || {},
} as Item;
}
/**
* Busca um item por nome (exato ou similar)
*/
export async function getItemByName(itemName: string): Promise<Item | null> {
const item = await prisma.item.findFirst({
where: {
item_name: {
equals: itemName,
mode: "insensitive",
},
},
});
if (!item) {
return null;
}
return {
...item,
weight: item.weight ? Number(item.weight) : null,
metadata: item.metadata || {},
} as Item;
}
/**
* Busca itens com filtros
*/
export async function searchItems(
params: SearchItemsParams = {}
): Promise<{ items: Item[]; total: number }> {
const {
q = "",
category,
rarity,
page = 1,
pageSize = 24,
} = params;
const where: Prisma.ItemWhereInput = {};
if (q) {
where.item_name = {
contains: q,
mode: "insensitive",
};
}
if (category) {
where.category = category;
}
if (rarity) {
where.rarity = rarity;
}
const total = await prisma.item.count({ where });
const items = await prisma.item.findMany({
where,
orderBy: { item_name: "asc" },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
items: items.map((item) => ({
...item,
weight: item.weight ? Number(item.weight) : null,
metadata: item.metadata || {},
})) as Item[],
total,
};
}
/**
* Busca item por source_table_name e source_table_id
* (útil para migração ou compatibilidade com IDs antigos)
*/
export async function getItemBySource(
sourceTableName: string,
sourceTableId: number
): Promise<Item | null> {
const item = await prisma.item.findUnique({
where: {
source_table_name_source_table_id: {
source_table_name: sourceTableName,
source_table_id: sourceTableId,
},
},
});
if (!item) {
return null;
}
return {
...item,
weight: item.weight ? Number(item.weight) : null,
metadata: item.metadata || {},
} as Item;
}
+589
View File
@@ -0,0 +1,589 @@
/**
* Queries de listings (anúncios) usando Prisma
* Migrado de lib/db/queries/listings.ts
*/
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
// Re-exportar tipos para compatibilidade
export type ListingStatus = "active" | "sold" | "cancelled" | "in_escrow" | "expired";
export 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;
current_bid_amount: number | null;
current_bidder_id: number | null;
auction_ends_at: Date | null;
description: string | null;
listing_status: ListingStatus;
created_at: Date;
updated_at: Date;
sold_at: Date | null;
views_count: number;
is_featured?: boolean;
favorites_count?: number;
inquiries_count?: number;
price_history?: any;
expires_at?: Date | null;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
min_bid_increment?: number | null;
initial_price_pix?: number | null;
initial_price_tibia_coins?: number | null;
initial_price_gold_coins?: number | null;
seller_character_name?: string | null;
}
export interface CreateListingData {
seller_id: number;
item_id: number;
item_name: string;
quantity: number;
server_name: string;
price_pix?: number;
price_tibia_coins?: number;
price_gold_coins?: number;
is_auction?: boolean;
auction_ends_at?: Date;
description?: string;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
min_bid_increment?: number;
}
export interface UpdateListingData {
quantity?: number;
price_pix?: number;
price_tibia_coins?: number;
price_gold_coins?: number;
description?: string;
listing_status?: ListingStatus;
current_bid_amount?: number;
current_bidder_id?: number;
}
export interface ListingFilters {
status?: ListingStatus;
server_name?: string;
item_id?: number;
seller_id?: number;
is_auction?: boolean;
search?: string;
page?: number;
pageSize?: number;
sortBy?: "created_at" | "price_pix" | "price_tibia_coins" | "price_gold_coins" | "views_count";
sortOrder?: "ASC" | "DESC";
}
/**
* Cria um novo anúncio
*/
export async function createListing(
data: CreateListingData
): Promise<Listing> {
// Calcular incremento mínimo se for leilão e não foi fornecido
let minBidIncrement = data.min_bid_increment;
if (data.is_auction && !minBidIncrement) {
const initialPrice = data.price_pix || 0;
minBidIncrement = Math.max(initialPrice * 0.05, 10.0);
}
const listing = await prisma.listing.create({
data: {
seller_id: data.seller_id,
item_id: data.item_id,
item_name: data.item_name,
quantity: data.quantity,
server_name: data.server_name,
price_pix: data.price_pix || null,
price_tibia_coins: data.price_tibia_coins || null,
price_gold_coins: data.price_gold_coins ? BigInt(data.price_gold_coins) : null,
is_auction: data.is_auction || false,
auction_ends_at: data.auction_ends_at || null,
description: data.description || null,
trade_item_id: data.trade_item_id || null,
trade_item_name: data.trade_item_name || null,
trade_item_quantity: data.trade_item_quantity || null,
min_bid_increment: minBidIncrement || null,
initial_price_pix: data.is_auction ? (data.price_pix || null) : null,
initial_price_tibia_coins: data.is_auction ? (data.price_tibia_coins || null) : null,
initial_price_gold_coins: data.is_auction && data.price_gold_coins ? BigInt(data.price_gold_coins) : null,
},
});
// Buscar character name separadamente
const character = await prisma.userCharacter.findFirst({
where: {
user_id: data.seller_id,
server_name: data.server_name,
is_main: true,
},
});
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
} as Listing;
}
/**
* Busca anúncio por ID
*/
export async function getListingById(listingId: number): Promise<Listing | null> {
const listing = await prisma.listing.findUnique({
where: { listing_id: listingId },
});
if (!listing) {
return null;
}
// Buscar character name separadamente
const character = await prisma.userCharacter.findFirst({
where: {
user_id: listing.seller_id,
server_name: listing.server_name,
is_main: true,
},
});
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
} as Listing;
}
/**
* Lista anúncios com filtros e paginação
*/
export async function getListings(
filters: ListingFilters = {}
): Promise<{ listings: Listing[]; total: number }> {
const {
status,
server_name,
item_id,
seller_id,
is_auction,
search,
page = 1,
pageSize = 20,
sortBy = "created_at",
sortOrder = "DESC",
} = filters;
const where: Prisma.ListingWhereInput = {};
if (status) {
where.listing_status = status;
}
if (server_name) {
where.server_name = server_name;
}
if (item_id) {
where.item_id = item_id;
}
if (seller_id) {
where.seller_id = seller_id;
}
if (is_auction !== undefined) {
where.is_auction = is_auction;
}
if (search) {
where.OR = [
{ item_name: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } },
];
}
// Validar sortBy
const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at";
const orderBy: Prisma.ListingOrderByWithRelationInput = {
[sortColumn]: sortOrder.toLowerCase() as "asc" | "desc",
};
// Contar total
const total = await prisma.listing.count({ where });
// Buscar listings com paginação
const listings = await prisma.listing.findMany({
where,
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
});
// Buscar character names para cada listing (otimizado com uma query por seller_id + server_name)
const sellerServerPairs = new Set(
listings.map((l) => `${l.seller_id}:${l.server_name}`)
);
const charactersMap = new Map<string, string>();
for (const pair of sellerServerPairs) {
const [sellerId, serverName] = pair.split(":");
const character = await prisma.userCharacter.findFirst({
where: {
user_id: parseInt(sellerId, 10),
server_name: serverName,
is_main: true,
},
});
if (character) {
charactersMap.set(pair, character.character_name);
}
}
// Converter todos os campos Decimal e BigInt para números JavaScript
const listingsWithCharacterNames = listings.map((listing) => {
const key = `${listing.seller_id}:${listing.server_name}`;
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: charactersMap.get(key) || null,
} as Listing;
});
return {
listings: listingsWithCharacterNames,
total,
};
}
/**
* Atualiza anúncio
*/
export async function updateListing(
listingId: number,
data: UpdateListingData
): Promise<Listing | null> {
const updateData: Prisma.ListingUpdateInput = {};
if (data.quantity !== undefined) updateData.quantity = data.quantity;
if (data.price_pix !== undefined) updateData.price_pix = data.price_pix;
if (data.price_tibia_coins !== undefined) updateData.price_tibia_coins = data.price_tibia_coins;
if (data.price_gold_coins !== undefined) updateData.price_gold_coins = BigInt(data.price_gold_coins);
if (data.description !== undefined) updateData.description = data.description;
if (data.listing_status !== undefined) updateData.listing_status = data.listing_status;
if (data.current_bid_amount !== undefined) updateData.current_bid_amount = data.current_bid_amount;
if (data.current_bidder_id !== undefined) {
updateData.current_bidder_id = data.current_bidder_id;
}
const listing = await prisma.listing.update({
where: { listing_id: listingId },
data: updateData,
});
// Buscar character name
const character = await prisma.userCharacter.findFirst({
where: {
user_id: listing.seller_id,
server_name: listing.server_name,
is_main: true,
},
});
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: character?.character_name || null,
} as Listing;
}
/**
* Remove anúncio
*/
export async function deleteListing(listingId: number): Promise<boolean> {
try {
await prisma.listing.delete({
where: { listing_id: listingId },
});
return true;
} catch (error) {
return false;
}
}
/**
* Incrementa contador de visualizações
*/
export async function incrementViews(listingId: number): Promise<void> {
await prisma.listing.update({
where: { listing_id: listingId },
data: {
views_count: {
increment: 1,
},
},
});
}
/**
* Lista anúncios de um vendedor
*/
export async function getListingsBySeller(
sellerId: number,
filters: { status?: ListingStatus; page?: number; pageSize?: number } = {}
): Promise<{ listings: Listing[]; total: number }> {
return getListings({
seller_id: sellerId,
status: filters.status,
page: filters.page,
pageSize: filters.pageSize,
});
}
/**
* Lista apenas anúncios ativos (incluindo vendidos nas últimas 1 hora)
*/
export async function getActiveListings(
filters: Omit<ListingFilters, "status"> = {}
): Promise<{ listings: Listing[]; total: number }> {
const {
server_name,
item_id,
seller_id,
is_auction,
search,
page = 1,
pageSize = 20,
sortBy = "created_at",
sortOrder = "DESC",
} = filters;
const where: Prisma.ListingWhereInput = {
OR: [
// Anúncios ativos
{ listing_status: "active" },
// Anúncios vendidos nas últimas 1 hora
{
listing_status: "sold",
sold_at: {
gte: new Date(Date.now() - 60 * 60 * 1000), // 1 hora atrás
},
},
],
};
if (server_name) {
where.server_name = server_name;
}
if (item_id) {
where.item_id = item_id;
}
if (seller_id) {
where.seller_id = seller_id;
}
if (is_auction !== undefined) {
where.is_auction = is_auction;
}
if (search) {
const searchCondition = {
OR: [
{ item_name: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } },
],
};
// Combinar com a condição OR existente
where.AND = [
{
OR: [
{ listing_status: "active" },
{
listing_status: "sold",
sold_at: {
gte: new Date(Date.now() - 60 * 60 * 1000),
},
},
],
},
searchCondition,
];
delete where.OR;
}
// Validar sortBy
const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at";
const orderBy: Prisma.ListingOrderByWithRelationInput = {
[sortColumn]: sortOrder.toLowerCase() as "asc" | "desc",
};
// Contar total
const total = await prisma.listing.count({ where });
// Buscar listings com paginação
const listings = await prisma.listing.findMany({
where,
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
});
// Buscar character names para cada listing
const sellerServerPairs = new Set(
listings.map((l) => `${l.seller_id}:${l.server_name}`)
);
const charactersMap = new Map<string, string>();
for (const pair of sellerServerPairs) {
const [sellerId, serverName] = pair.split(":");
const character = await prisma.userCharacter.findFirst({
where: {
user_id: parseInt(sellerId, 10),
server_name: serverName,
is_main: true,
},
});
if (character) {
charactersMap.set(pair, character.character_name);
}
}
// Converter todos os campos Decimal e BigInt para números JavaScript
const listingsWithCharacterNames = listings.map((listing) => {
const key = `${listing.seller_id}:${listing.server_name}`;
return {
...listing,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
current_bid_amount: listing.current_bid_amount ? Number(listing.current_bid_amount) : null,
min_bid_increment: listing.min_bid_increment ? Number(listing.min_bid_increment) : null,
initial_price_pix: listing.initial_price_pix ? Number(listing.initial_price_pix) : null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null,
seller_character_name: charactersMap.get(key) || null,
} as Listing;
});
return {
listings: listingsWithCharacterNames,
total,
};
}
/**
* Coloca um lance em um leilão
*/
export async function placeBid(
listingId: number,
bidderId: number,
amount: number,
paymentMethod: "pix" | "tc" | "gp"
): Promise<{ success: boolean; listing?: Listing; error?: string; previousBidderId?: number }> {
// Usar transação para garantir atomicidade
return await prisma.$transaction(async (tx) => {
// Buscar listing atual
const listing = await tx.listing.findUnique({
where: { listing_id: listingId },
});
if (!listing) {
return { success: false, error: "Anúncio não encontrado" };
}
if (!listing.is_auction) {
return { success: false, error: "Este anúncio não é um leilão" };
}
if (listing.listing_status !== "active") {
return { success: false, error: "Leilão não está ativo" };
}
if (listing.seller_id === bidderId) {
return { success: false, error: "Você não pode dar lance no seu próprio anúncio" };
}
// Validar valor mínimo
const minIncrement = listing.min_bid_increment ? Number(listing.min_bid_increment) : 10;
const currentBid = listing.current_bid_amount ? Number(listing.current_bid_amount) : 0;
const minBid = currentBid + minIncrement;
if (amount < minBid) {
return { success: false, error: `O lance deve ser de pelo menos ${minBid.toFixed(2)}` };
}
const previousBidderId = listing.current_bidder_id;
// Atualizar listing
const updatedListing = await tx.listing.update({
where: { listing_id: listingId },
data: {
current_bid_amount: amount,
current_bidder_id: bidderId,
},
});
// Buscar character name
const character = await tx.userCharacter.findFirst({
where: {
user_id: updatedListing.seller_id,
server_name: updatedListing.server_name,
is_main: true,
},
});
// Converter todos os campos Decimal e BigInt para números JavaScript
return {
success: true,
listing: {
...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,
} as Listing,
previousBidderId: previousBidderId || undefined,
};
});
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Queries de notificações usando Prisma
* Migrado de lib/db/queries/notifications.ts
*/
import { prisma } from "../app";
export type NotificationType = "bid" | "sale" | "purchase" | "escrow" | "dispute" | "system";
export interface Notification {
notification_id: number;
user_id: number;
notification_type: NotificationType;
title: string;
message: string;
related_id: number | null;
is_read: boolean;
send_whatsapp: boolean;
whatsapp_sent: boolean;
created_at: Date;
}
export interface CreateNotificationData {
user_id: number;
notification_type: NotificationType;
title: string;
message: string;
related_id?: number | null;
send_whatsapp?: boolean;
}
/**
* Cria uma nova notificação
*/
export async function createNotification(
data: CreateNotificationData
): Promise<Notification> {
return await prisma.notification.create({
data: {
user_id: data.user_id,
notification_type: data.notification_type,
title: data.title,
message: data.message,
related_id: data.related_id || null,
send_whatsapp: data.send_whatsapp || false,
},
});
}
/**
* Busca notificações de um usuário
*/
export async function getUserNotifications(
userId: number,
filters: { is_read?: boolean; type?: NotificationType; limit?: number } = {}
): Promise<Notification[]> {
const { is_read, type, limit = 50 } = filters;
const where: any = { user_id: userId };
if (is_read !== undefined) {
where.is_read = is_read;
}
if (type) {
where.notification_type = type;
}
return await prisma.notification.findMany({
where,
orderBy: { created_at: "desc" },
take: limit,
});
}
/**
* Conta notificações não lidas de um usuário
*/
export async function getUnreadCount(userId: number): Promise<number> {
return await prisma.notification.count({
where: {
user_id: userId,
is_read: false,
},
});
}
/**
* Marca notificação como lida
*/
export async function markAsRead(notificationId: number, userId: number): Promise<boolean> {
const result = await prisma.notification.updateMany({
where: {
notification_id: notificationId,
user_id: userId,
},
data: {
is_read: true,
},
});
return result.count > 0;
}
/**
* Marca todas as notificações de um usuário como lidas
*/
export async function markAllAsRead(userId: number): Promise<number> {
const result = await prisma.notification.updateMany({
where: {
user_id: userId,
is_read: false,
},
data: {
is_read: true,
},
});
return result.count;
}
/**
* Remove notificação
*/
export async function deleteNotification(notificationId: number, userId: number): Promise<boolean> {
try {
await prisma.notification.delete({
where: {
notification_id: notificationId,
user_id: userId,
},
});
return true;
} catch (error) {
return false;
}
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Queries de Purchase Intentions usando Prisma
*/
import { prisma } from "../app";
export type PurchaseIntentionStatus = "pending" | "accepted" | "rejected" | "expired" | "completed";
export type PaymentMethod = "pix" | "tibia_coins" | "gold_coins" | "trade";
export interface CreatePurchaseIntentionData {
listing_id: number;
buyer_id: number;
payment_method: PaymentMethod;
buyer_message?: string;
expires_at?: Date;
}
export interface PurchaseIntention {
intention_id: number;
listing_id: number;
buyer_id: number;
payment_method: string;
status: PurchaseIntentionStatus;
buyer_message: string | null;
seller_response: string | null;
created_at: Date;
updated_at: Date;
expires_at: Date | null;
accepted_at: Date | null;
rejected_at: Date | null;
// Relacionamentos
listing?: {
listing_id: number;
item_name: string;
seller_id: number;
};
buyer?: {
user_id: number;
username: string;
email: string;
};
}
/**
* Cria uma nova intenção de compra
*/
export async function createPurchaseIntention(
data: CreatePurchaseIntentionData
): Promise<PurchaseIntention> {
return await prisma.purchaseIntention.create({
data: {
listing_id: data.listing_id,
buyer_id: data.buyer_id,
payment_method: data.payment_method,
buyer_message: data.buyer_message || null,
expires_at: data.expires_at || null,
status: "pending",
},
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
}) as PurchaseIntention;
}
/**
* Busca intenções de um anúncio específico
*/
export async function getPurchaseIntentionsByListing(
listingId: number,
filters: { status?: PurchaseIntentionStatus; limit?: number } = {}
): Promise<PurchaseIntention[]> {
const { status, limit = 50 } = filters;
const where: any = { listing_id: listingId };
if (status) {
where.status = status;
}
return await prisma.purchaseIntention.findMany({
where,
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
orderBy: { created_at: "desc" },
take: limit,
}) as PurchaseIntention[];
}
/**
* Busca intenções de um comprador
*/
export async function getPurchaseIntentionsByBuyer(
buyerId: number,
filters: { status?: PurchaseIntentionStatus; limit?: number } = {}
): Promise<PurchaseIntention[]> {
const { status, limit = 50 } = filters;
const where: any = { buyer_id: buyerId };
if (status) {
where.status = status;
}
return await prisma.purchaseIntention.findMany({
where,
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
orderBy: { created_at: "desc" },
take: limit,
}) as PurchaseIntention[];
}
/**
* Busca uma intenção específica por ID
*/
export async function getPurchaseIntentionById(
intentionId: number
): Promise<PurchaseIntention | null> {
return await prisma.purchaseIntention.findUnique({
where: { intention_id: intentionId },
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
}) as PurchaseIntention | null;
}
/**
* Atualiza o status de uma intenção
*/
export async function updatePurchaseIntentionStatus(
intentionId: number,
status: PurchaseIntentionStatus,
sellerResponse?: string
): Promise<PurchaseIntention> {
const updateData: any = {
status,
updated_at: new Date(),
};
if (status === "accepted") {
updateData.accepted_at = new Date();
updateData.seller_response = sellerResponse || null;
} else if (status === "rejected") {
updateData.rejected_at = new Date();
updateData.seller_response = sellerResponse || null;
} else if (sellerResponse) {
updateData.seller_response = sellerResponse;
}
return await prisma.purchaseIntention.update({
where: { intention_id: intentionId },
data: updateData,
include: {
listing: {
select: {
listing_id: true,
item_name: true,
seller_id: true,
},
},
buyer: {
select: {
user_id: true,
username: true,
email: true,
},
},
},
}) as PurchaseIntention;
}
/**
* Conta intenções pendentes de um anúncio
*/
export async function getPendingIntentionsCount(listingId: number): Promise<number> {
return await prisma.purchaseIntention.count({
where: {
listing_id: listingId,
status: "pending",
},
});
}
/**
* Verifica se já existe uma intenção pendente para um listing+buyer
*/
export async function hasPendingIntention(
listingId: number,
buyerId: number
): Promise<boolean> {
const count = await prisma.purchaseIntention.count({
where: {
listing_id: listingId,
buyer_id: buyerId,
status: "pending",
},
});
return count > 0;
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Queries de servidores usando Prisma
* Migrado de lib/db/queries/servers.ts
*/
import { prisma } from "../app";
export interface Server {
server_id: number;
server_name: string;
server_type: "pvp" | "no-pvp" | "retro-pvp" | "hardcore-pvp" | null;
location: "NA" | "SA" | "EU" | "OC" | "BR" | null;
is_active: boolean;
total_listings: number;
total_users: number;
total_subscribers: number;
total_transactions: number;
total_volume: number;
average_listing_price: number;
last_activity_at: Date | null;
created_at: Date;
updated_at: Date;
}
export interface ServerStats {
total_listings?: number;
total_users?: number;
total_subscribers?: number;
total_transactions?: number;
total_volume?: number;
average_listing_price?: number;
last_activity_at?: Date;
}
/**
* Lista todos os servidores
*/
export async function getAllServers(): Promise<Server[]> {
return await prisma.server.findMany({
orderBy: { server_name: "asc" },
});
}
/**
* Lista apenas servidores ativos
*/
export async function getActiveServers(): Promise<Server[]> {
return await prisma.server.findMany({
where: { is_active: true },
orderBy: { server_name: "asc" },
});
}
/**
* Busca servidor por nome
*/
export async function getServerByName(serverName: string): Promise<Server | null> {
return await prisma.server.findUnique({
where: { server_name: serverName },
});
}
/**
* Busca servidor por ID
*/
export async function getServerById(serverId: number): Promise<Server | null> {
return await prisma.server.findUnique({
where: { server_id: serverId },
});
}
/**
* Verifica se servidor existe e está ativo
*/
export async function isServerActive(serverName: string): Promise<boolean> {
const count = await prisma.server.count({
where: {
server_name: serverName,
is_active: true,
},
});
return count > 0;
}
/**
* Incrementa estatísticas do servidor
*/
export async function incrementServerStats(
serverName: string,
stats: Partial<{
total_listings: number;
total_users: number;
total_subscribers: number;
total_transactions: number;
total_volume: number;
}>
): Promise<void> {
const updateData: any = {
last_activity_at: new Date(),
};
if (stats.total_listings !== undefined) {
updateData.total_listings = { increment: stats.total_listings };
}
if (stats.total_users !== undefined) {
updateData.total_users = { increment: stats.total_users };
}
if (stats.total_subscribers !== undefined) {
updateData.total_subscribers = { increment: stats.total_subscribers };
}
if (stats.total_transactions !== undefined) {
updateData.total_transactions = { increment: stats.total_transactions };
}
if (stats.total_volume !== undefined) {
updateData.total_volume = { increment: stats.total_volume };
}
await prisma.server.update({
where: { server_name: serverName },
data: updateData,
});
}
/**
* Atualiza estatísticas completas do servidor
*/
export async function updateServerStats(
serverName: string,
stats: ServerStats
): Promise<void> {
const updateData: any = {};
if (stats.total_listings !== undefined) updateData.total_listings = stats.total_listings;
if (stats.total_users !== undefined) updateData.total_users = stats.total_users;
if (stats.total_subscribers !== undefined) updateData.total_subscribers = stats.total_subscribers;
if (stats.total_transactions !== undefined) updateData.total_transactions = stats.total_transactions;
if (stats.total_volume !== undefined) updateData.total_volume = stats.total_volume;
if (stats.average_listing_price !== undefined) updateData.average_listing_price = stats.average_listing_price;
if (stats.last_activity_at !== undefined) updateData.last_activity_at = stats.last_activity_at;
if (Object.keys(updateData).length === 0) {
return;
}
await prisma.server.update({
where: { server_name: serverName },
data: updateData,
});
}
/**
* Incrementa total_users de múltiplos servidores
*/
export async function incrementUsersForServers(serverNames: string[]): Promise<void> {
if (serverNames.length === 0) {
return;
}
await prisma.server.updateMany({
where: {
server_name: { in: serverNames },
},
data: {
total_users: { increment: 1 },
last_activity_at: new Date(),
},
});
}
+604
View File
@@ -0,0 +1,604 @@
/**
* Queries de Telemetria usando Prisma
* Migrado de lib/db/queries/telemetry.ts
*/
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
export type TelemetryEventType =
| "ad_view"
| "ad_click"
| "ad_exit"
| "search"
| "filter"
| "favorite"
| "unfavorite"
| "purchase"
| "purchase_intention_created"
| "purchase_intention_viewed"
| "bid_placed"
| "bid_viewed"
| "seller_profile_viewed"
| "inquiry"
| "page_view"
| "price_change";
export interface TelemetryEvent {
event_id: number;
event_type: TelemetryEventType;
ad_id: number | null;
user_id: number | null;
session_id: string | null;
metadata: Record<string, any>;
timestamp: Date;
}
export interface TrackEventData {
event_type: TelemetryEventType;
ad_id?: number;
user_id?: number;
session_id?: string;
metadata?: Record<string, any>;
}
export 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;
}
export interface UserBehavior {
user_id: number;
search_queries: string[];
filters_applied: Record<string, any>;
items_viewed: Array<{
item_id: number;
duration: number;
timestamp: Date;
}>;
items_favorited: number[];
purchases: Array<{
ad_id: number;
price: number;
timestamp: Date;
}>;
active_hours: Array<{
hour: number;
activity_count: number;
}>;
}
export interface MarketplaceStats {
total_transactions: number;
avg_price_by_category: Record<string, number>;
conversion_rate: number;
most_searched_items: Array<{
item_id: number;
item_name: string;
search_count: number;
}>;
most_sold_items: Array<{
item_id: number;
item_name: string;
sales_count: number;
}>;
peak_hours: Array<{
hour: number;
activity_count: number;
}>;
avg_time_to_sell: number;
}
/**
* Registra um evento de telemetria
*/
export async function trackEvent(
data: TrackEventData
): Promise<TelemetryEvent> {
return await prisma.telemetryEvent.create({
data: {
event_type: data.event_type,
ad_id: data.ad_id || null,
user_id: data.user_id || null,
session_id: data.session_id || null,
metadata: data.metadata || {},
},
});
}
/**
* Registra múltiplos eventos em batch
*/
export async function trackEventsBatch(
events: TrackEventData[]
): Promise<TelemetryEvent[]> {
if (events.length === 0) return [];
return await prisma.$transaction(
async (tx) => {
return await Promise.all(
events.map((event) =>
tx.telemetryEvent.create({
data: {
event_type: event.event_type,
ad_id: event.ad_id || null,
user_id: event.user_id || null,
session_id: event.session_id || null,
metadata: event.metadata || {},
},
})
)
);
}
);
}
/**
* Busca analytics de um anúncio específico
*/
export async function getAdAnalytics(adId: number): Promise<AdAnalytics | null> {
return await prisma.adAnalytics.findUnique({
where: { ad_id: adId },
});
}
/**
* Busca comportamento do usuário (comprador)
*/
export async function getUserBehavior(
userId: number,
startDate?: Date,
endDate?: Date
): Promise<UserBehavior | null> {
// Verificar se usuário existe
const user = await prisma.user.findUnique({
where: { user_id: userId },
select: { user_id: true },
});
if (!user) {
return null;
}
const dateFilter: Prisma.TelemetryEventWhereInput = {};
if (startDate && endDate) {
dateFilter.timestamp = {
gte: startDate,
lte: endDate,
};
}
// Buscar queries de busca
const searchEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "search",
...dateFilter,
},
select: {
metadata: true,
},
});
// Buscar filtros aplicados
const filterEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "filter",
...dateFilter,
},
select: {
metadata: true,
},
});
// Buscar itens visualizados
const viewEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "ad_view",
ad_id: { not: null },
...dateFilter,
},
select: {
ad_id: true,
metadata: true,
timestamp: true,
},
orderBy: { timestamp: "desc" },
});
// Buscar favoritos
const favoriteEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "favorite",
ad_id: { not: null },
...dateFilter,
},
select: {
ad_id: true,
},
distinct: ["ad_id"],
});
// Buscar compras
const purchaseEvents = await prisma.telemetryEvent.findMany({
where: {
user_id: userId,
event_type: "purchase",
ad_id: { not: null },
...dateFilter,
},
select: {
ad_id: true,
metadata: true,
timestamp: true,
},
orderBy: { timestamp: "desc" },
});
// Buscar horas ativas
const activeHoursRaw = await prisma.$queryRaw<
Array<{ hour: number; count: bigint }>
>`
SELECT
EXTRACT(HOUR FROM timestamp)::INTEGER as hour,
COUNT(*) as count
FROM telemetry_events
WHERE user_id = ${userId}
${startDate && endDate ? Prisma.sql`AND timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
GROUP BY hour
ORDER BY hour
`;
// Buscar item_id dos anúncios visualizados
const itemIds = await prisma.$queryRaw<Array<{ item_id: number }>>`
SELECT DISTINCT l.item_id
FROM telemetry_events te
JOIN listings l ON l.listing_id = te.ad_id
WHERE te.user_id = ${userId}
AND te.event_type = 'ad_view'
AND te.ad_id IS NOT NULL
${startDate && endDate ? Prisma.sql`AND te.timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
`;
return {
user_id: userId,
search_queries: searchEvents
.map((r) => (r.metadata as any)?.query)
.filter((q): q is string => !!q),
filters_applied: filterEvents.reduce(
(acc, r) => ({ ...acc, ...(r.metadata as any) }),
{} as Record<string, any>
),
items_viewed: viewEvents.map((r) => ({
item_id: r.ad_id!,
duration: ((r.metadata as any)?.duration || 0) as number,
timestamp: r.timestamp,
})),
items_favorited: favoriteEvents.map((r) => r.ad_id!),
purchases: purchaseEvents.map((r) => ({
ad_id: r.ad_id!,
price: ((r.metadata as any)?.price || 0) as number,
timestamp: r.timestamp,
})),
active_hours: activeHoursRaw.map((r) => ({
hour: r.hour,
activity_count: Number(r.count),
})),
};
}
/**
* Busca métricas agregadas do marketplace
*/
export async function getMarketplaceStats(
startDate?: Date,
endDate?: Date
): Promise<MarketplaceStats> {
const dateFilter = startDate && endDate
? Prisma.sql`WHERE timestamp BETWEEN ${startDate} AND ${endDate}`
: Prisma.empty;
// Total de transações
const transactionsResult = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::TEXT as count
FROM telemetry_events
WHERE event_type = 'purchase'
${dateFilter}
`;
const total_transactions = Number(transactionsResult[0]?.count || 0);
// Preço médio por categoria
const avgPriceResult = await prisma.$queryRaw<
Array<{ item_name: string; avg_price: string }>
>`
SELECT
l.item_name,
AVG(COALESCE(l.price_pix, l.price_tibia_coins, l.price_gold_coins))::TEXT as avg_price
FROM listings l
JOIN telemetry_events te ON te.ad_id = l.listing_id
WHERE te.event_type = 'purchase'
${startDate && endDate ? Prisma.sql`AND te.timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
GROUP BY l.item_name
LIMIT 10
`;
// Taxa de conversão
const viewsResult = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'ad_view'
${dateFilter}
`;
const purchasesResult = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'purchase'
${dateFilter}
`;
const viewsCount = Number(viewsResult[0]?.count || 0);
const purchasesCount = Number(purchasesResult[0]?.count || 0);
const conversion_rate = viewsCount > 0 ? (purchasesCount / viewsCount) * 100 : 0;
// Itens mais procurados
const mostSearchedResult = await prisma.$queryRaw<
Array<{ item_name: string; search_count: bigint }>
>`
SELECT
(metadata->>'query') as item_name,
COUNT(*) as search_count
FROM telemetry_events
WHERE event_type = 'search' AND (metadata->>'query') IS NOT NULL
${dateFilter}
GROUP BY (metadata->>'query')
ORDER BY COUNT(*) DESC
LIMIT 10
`;
// Itens mais vendidos
const mostSoldResult = await prisma.$queryRaw<
Array<{ item_id: number; item_name: string; sales_count: bigint }>
>`
SELECT
l.item_id,
l.item_name,
COUNT(*) as sales_count
FROM telemetry_events te
JOIN listings l ON l.listing_id = te.ad_id
WHERE te.event_type = 'purchase'
${startDate && endDate ? Prisma.sql`AND te.timestamp BETWEEN ${startDate} AND ${endDate}` : Prisma.empty}
GROUP BY l.item_id, l.item_name
ORDER BY COUNT(*) DESC
LIMIT 10
`;
// Horários de pico
const peakHoursResult = await prisma.$queryRaw<
Array<{ hour: number; activity_count: bigint }>
>`
SELECT
EXTRACT(HOUR FROM timestamp)::INTEGER as hour,
COUNT(*) as activity_count
FROM telemetry_events
${dateFilter}
GROUP BY hour
ORDER BY COUNT(*) DESC
LIMIT 10
`;
// Tempo médio para venda
const avgTimeResult = await prisma.$queryRaw<
Array<{ avg_hours: string }>
>`
SELECT AVG(aa.time_to_sell)::TEXT as avg_hours
FROM ad_analytics aa
WHERE aa.time_to_sell IS NOT NULL
`;
return {
total_transactions,
avg_price_by_category: avgPriceResult.reduce(
(acc, r) => {
acc[r.item_name] = parseFloat(r.avg_price);
return acc;
},
{} as Record<string, number>
),
conversion_rate,
most_searched_items: mostSearchedResult.map((r) => ({
item_id: 0,
item_name: r.item_name || "",
search_count: Number(r.search_count),
})),
most_sold_items: mostSoldResult.map((r) => ({
item_id: r.item_id,
item_name: r.item_name,
sales_count: Number(r.sales_count),
})),
peak_hours: peakHoursResult.map((r) => ({
hour: r.hour,
activity_count: Number(r.activity_count),
})),
avg_time_to_sell: parseFloat(avgTimeResult[0]?.avg_hours || "0"),
};
}
/**
* Agrega eventos de telemetria em métricas de analytics
* Executar periodicamente (ex: a cada hora)
*/
export async function aggregateHourlyMetrics(): Promise<void> {
// Usar $queryRaw para queries complexas de agregação
// Por enquanto, manter a lógica original mas adaptada para Prisma
// Agregar impressões
await prisma.$executeRaw`
INSERT INTO ad_analytics (ad_id, impressions, last_updated)
SELECT
ad_id,
COUNT(*) as impressions,
CURRENT_TIMESTAMP
FROM telemetry_events
WHERE event_type = 'ad_view'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
ON CONFLICT (ad_id) DO UPDATE SET
impressions = ad_analytics.impressions + EXCLUDED.impressions,
last_updated = CURRENT_TIMESTAMP
`;
// Agregar cliques
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET clicks = clicks + sub.count,
last_updated = CURRENT_TIMESTAMP
FROM (
SELECT ad_id, COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'ad_click'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
) sub
WHERE aa.ad_id = sub.ad_id
`;
// Atualizar CTR
await prisma.$executeRaw`
UPDATE ad_analytics
SET ctr = CASE
WHEN impressions > 0 THEN (clicks::DECIMAL / impressions::DECIMAL) * 100
ELSE 0
END,
last_updated = CURRENT_TIMESTAMP
WHERE impressions > 0
`;
// Agregar duração média de visualização
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET
avg_view_duration = COALESCE(
(
SELECT AVG((metadata->>'duration')::DECIMAL)
FROM telemetry_events
WHERE event_type = 'ad_exit'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
AND metadata->>'duration' IS NOT NULL
),
avg_view_duration
),
last_updated = CURRENT_TIMESTAMP
WHERE EXISTS (
SELECT 1 FROM telemetry_events
WHERE event_type = 'ad_exit'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
)
`;
// Calcular bounce rate
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET
bounce_rate = CASE
WHEN aa.impressions > 0 THEN
(
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
FROM telemetry_events
WHERE event_type = 'ad_exit'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
AND (metadata->>'duration')::DECIMAL < 3
)
ELSE 0
END,
last_updated = CURRENT_TIMESTAMP
WHERE aa.impressions > 0
`;
// Agregar inquiries
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET inquiries = inquiries + sub.count,
last_updated = CURRENT_TIMESTAMP
FROM (
SELECT ad_id, COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'inquiry'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
) sub
WHERE aa.ad_id = sub.ad_id
`;
// Agregar favoritos
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET favorites = favorites + sub.count,
last_updated = CURRENT_TIMESTAMP
FROM (
SELECT ad_id, COUNT(*) as count
FROM telemetry_events
WHERE event_type = 'favorite'
AND ad_id IS NOT NULL
AND timestamp >= NOW() - INTERVAL '1 hour'
GROUP BY ad_id
) sub
WHERE aa.ad_id = sub.ad_id
`;
// Calcular taxa de conversão
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET
conversion_rate = CASE
WHEN aa.impressions > 0 THEN
(
SELECT (COUNT(*)::DECIMAL / aa.impressions::DECIMAL) * 100
FROM telemetry_events
WHERE event_type = 'purchase'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
)
ELSE 0
END,
last_updated = CURRENT_TIMESTAMP
WHERE EXISTS (
SELECT 1 FROM telemetry_events
WHERE event_type = 'purchase'
AND ad_id = aa.ad_id
AND timestamp >= NOW() - INTERVAL '1 hour'
)
`;
// Calcular time_to_sell
await prisma.$executeRaw`
UPDATE ad_analytics aa
SET
time_to_sell = EXTRACT(EPOCH FROM (l.sold_at - l.created_at)) / 3600,
last_updated = CURRENT_TIMESTAMP
FROM listings l
WHERE aa.ad_id = l.listing_id
AND l.listing_status = 'sold'
AND l.sold_at IS NOT NULL
AND aa.time_to_sell IS NULL
AND l.sold_at >= NOW() - INTERVAL '1 hour'
`;
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Queries de transactions usando Prisma
* Migrado de lib/db/queries/transactions.ts
*/
import { prisma } from "../app";
import { Prisma } from "@prisma/client";
export type TransactionStatus =
| "pending"
| "deposited"
| "item_delivered"
| "completed"
| "disputed"
| "cancelled"
| "refunded";
export interface Transaction {
transaction_id: number;
listing_id: number | null;
buyer_id: number;
seller_id: number;
item_name: string;
quantity: number;
server_name: string;
amount: number;
payment_method: "pix" | "tibia_coins" | "gold_coins" | null;
escrow_fee: number | null;
transaction_status: TransactionStatus;
created_at: Date;
deposited_at: Date | null;
delivered_at: Date | null;
completed_at: Date | null;
buyer_confirmed: boolean;
seller_confirmed: boolean;
buyer_notes: string | null;
seller_notes: string | null;
}
export interface CreateTransactionData {
listing_id?: number;
buyer_id: number;
seller_id: number;
item_name: string;
quantity: number;
server_name: string;
amount: number;
payment_method: "pix" | "tibia_coins" | "gold_coins";
escrow_fee?: number;
}
export interface UpdateTransactionStatusData {
transaction_status: TransactionStatus;
deposited_at?: Date;
delivered_at?: Date;
completed_at?: Date;
buyer_confirmed?: boolean;
seller_confirmed?: boolean;
buyer_notes?: string;
seller_notes?: string;
}
/**
* Cria uma nova transação
*/
export async function createTransaction(
data: CreateTransactionData
): Promise<Transaction> {
// Calcular escrow_fee se não fornecido (2% do valor)
const escrowFee = data.escrow_fee ?? Number((data.amount * 0.02).toFixed(2));
return await prisma.transaction.create({
data: {
listing_id: data.listing_id || null,
buyer_id: data.buyer_id,
seller_id: data.seller_id,
item_name: data.item_name,
quantity: data.quantity,
server_name: data.server_name,
amount: data.amount,
payment_method: data.payment_method,
escrow_fee: escrowFee,
},
});
}
/**
* Busca transação por ID
*/
export async function getTransactionById(
transactionId: number
): Promise<Transaction | null> {
return await prisma.transaction.findUnique({
where: { transaction_id: transactionId },
});
}
/**
* Atualiza status da transação
*/
export async function updateTransactionStatus(
transactionId: number,
data: UpdateTransactionStatusData
): Promise<Transaction | null> {
const updateData: Prisma.TransactionUpdateInput = {
transaction_status: data.transaction_status,
};
if (data.deposited_at !== undefined) updateData.deposited_at = data.deposited_at;
if (data.delivered_at !== undefined) updateData.delivered_at = data.delivered_at;
if (data.completed_at !== undefined) updateData.completed_at = data.completed_at;
if (data.buyer_confirmed !== undefined) updateData.buyer_confirmed = data.buyer_confirmed;
if (data.seller_confirmed !== undefined) updateData.seller_confirmed = data.seller_confirmed;
if (data.buyer_notes !== undefined) updateData.buyer_notes = data.buyer_notes;
if (data.seller_notes !== undefined) updateData.seller_notes = data.seller_notes;
return await prisma.transaction.update({
where: { transaction_id: transactionId },
data: updateData,
});
}
/**
* Busca transações de um usuário (comprador ou vendedor)
*/
export async function getTransactionsByUser(
userId: number,
filters: {
role?: "buyer" | "seller" | "both";
status?: TransactionStatus;
page?: number;
pageSize?: number;
} = {}
): Promise<{ transactions: Transaction[]; total: number }> {
const { role = "both", status, page = 1, pageSize = 20 } = filters;
const where: Prisma.TransactionWhereInput = {};
if (role === "buyer") {
where.buyer_id = userId;
} else if (role === "seller") {
where.seller_id = userId;
} else {
where.OR = [
{ buyer_id: userId },
{ seller_id: userId },
];
}
if (status) {
where.transaction_status = status;
}
// Contar total
const total = await prisma.transaction.count({ where });
// Buscar transações
const transactions = await prisma.transaction.findMany({
where,
orderBy: { created_at: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
transactions,
total,
};
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Queries de usuários usando Prisma
* Migrado de lib/db/queries/users.ts
*/
import { prisma } from "../app";
import type { User } from "@prisma/client";
export interface CreateUserData {
username: string;
email: string;
password_hash: string;
first_name: string;
last_name: string;
phone?: string;
cpf?: string;
}
export interface UpdateUserData {
first_name?: string;
last_name?: string;
phone?: string;
whatsapp_notifications?: boolean;
}
/**
* Cria um novo usuário
*/
export async function createUser(
data: CreateUserData
): Promise<{ userId: number; username: string; email: string }> {
const user = await prisma.user.create({
data: {
username: data.username,
email: data.email,
password_hash: data.password_hash,
first_name: data.first_name,
last_name: data.last_name,
phone: data.phone || null,
cpf: data.cpf || null,
},
select: {
user_id: true,
username: true,
email: true,
},
});
return {
userId: user.user_id,
username: user.username,
email: user.email,
};
}
/**
* Busca usuário por email (inclui password_hash para login)
*/
export async function getUserByEmail(email: string): Promise<User | null> {
return await prisma.user.findUnique({
where: { email },
});
}
/**
* Busca usuário por username
*/
export async function getUserByUsername(
username: string
): Promise<User | null> {
return await prisma.user.findUnique({
where: { username },
});
}
/**
* Busca usuário por ID (sem password_hash)
*/
export async function getUserById(userId: number): Promise<User | null> {
return await prisma.user.findUnique({
where: { user_id: userId },
select: {
user_id: true,
username: true,
email: true,
first_name: true,
last_name: true,
phone: true,
cpf: true,
account_level: true,
has_donated: true,
whatsapp_notifications: true,
created_at: true,
updated_at: true,
last_login: true,
is_active: true,
is_verified: true,
},
});
}
/**
* Busca usuário por CPF (verifica duplicidade)
*/
export async function getUserByCpf(cpf: string): Promise<User | null> {
return await prisma.user.findUnique({
where: { cpf },
select: {
user_id: true,
username: true,
email: true,
},
});
}
/**
* Atualiza dados do usuário
*/
export async function updateUser(
userId: number,
data: UpdateUserData
): Promise<User> {
const updateData: any = {};
if (data.first_name !== undefined) updateData.first_name = data.first_name;
if (data.last_name !== undefined) updateData.last_name = data.last_name;
if (data.phone !== undefined) updateData.phone = data.phone || null;
if (data.whatsapp_notifications !== undefined)
updateData.whatsapp_notifications = data.whatsapp_notifications;
const user = await prisma.user.update({
where: { user_id: userId },
data: updateData,
select: {
user_id: true,
username: true,
email: true,
first_name: true,
last_name: true,
phone: true,
cpf: true,
account_level: true,
has_donated: true,
whatsapp_notifications: true,
created_at: true,
updated_at: true,
last_login: true,
is_active: true,
is_verified: true,
},
});
return user;
}
/**
* Atualiza timestamp de último login
*/
export async function updateLastLogin(userId: number): Promise<void> {
await prisma.user.update({
where: { user_id: userId },
data: { last_login: new Date() },
});
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minuto - dados considerados frescos
refetchOnWindowFocus: true, // Refetch quando a janela recebe foco
refetchOnReconnect: true, // Refetch quando reconecta
retry: 1, // Tentar apenas 1 vez em caso de erro
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}
+361
View File
@@ -0,0 +1,361 @@
/**
* Classe para tracking de telemetria no marketplace
* Gerencia eventos client-side com debouncing e batch sending
*/
type TelemetryEventType =
| "ad_view"
| "ad_click"
| "ad_exit"
| "search"
| "filter"
| "favorite"
| "unfavorite"
| "purchase"
| "purchase_intention_created"
| "purchase_intention_viewed"
| "bid_placed"
| "bid_viewed"
| "seller_profile_viewed"
| "inquiry"
| "page_view"
| "price_change";
interface TelemetryEvent {
event_type: TelemetryEventType;
ad_id?: number | null;
session_id?: string;
metadata?: Record<string, any>;
}
interface PendingEvent extends TelemetryEvent {
timestamp: number;
}
export class MarketTelemetry {
private sessionId: string;
private userId?: number;
private eventQueue: PendingEvent[] = [];
private viewStartTimes: Map<number, number> = new Map();
private batchInterval: NodeJS.Timeout | null = null;
private readonly BATCH_INTERVAL_MS = 30000; // 30 segundos
private readonly MAX_BATCH_SIZE = 100;
constructor(userId?: number) {
this.userId = userId;
this.sessionId = this.generateSessionId();
this.startBatchInterval();
this.setupPageUnloadHandler();
}
/**
* Gera um ID de sessão único
*/
private generateSessionId(): string {
if (typeof window === "undefined") return "";
// Tentar recuperar da sessionStorage
let sessionId = sessionStorage.getItem("telemetry_session_id");
if (sessionId) return sessionId;
// Gerar novo ID
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem("telemetry_session_id", sessionId);
return sessionId;
}
/**
* Inicia tracking de visualização de anúncio
*/
trackAdView(adId: number): void {
if (typeof window === "undefined") return;
const startTime = Date.now();
this.viewStartTimes.set(adId, startTime);
this.queueEvent({
event_type: "ad_view",
ad_id: adId,
metadata: {
timestamp: startTime,
},
});
}
/**
* Finaliza tracking de visualização com duração
*/
trackAdExit(adId: number): void {
if (typeof window === "undefined") return;
const startTime = this.viewStartTimes.get(adId);
if (!startTime) return;
const duration = (Date.now() - startTime) / 1000; // em segundos
this.viewStartTimes.delete(adId);
this.queueEvent({
event_type: "ad_exit",
ad_id: adId,
metadata: {
duration,
timestamp: Date.now(),
},
});
}
/**
* Registra clique em anúncio
*/
trackAdClick(adId: number, position?: number): void {
this.queueEvent({
event_type: "ad_click",
ad_id: adId,
metadata: {
position,
timestamp: Date.now(),
},
});
}
/**
* Registra busca
*/
trackSearch(query: string, filters?: Record<string, any>): void {
this.queueEvent({
event_type: "search",
metadata: {
query,
filters,
timestamp: Date.now(),
},
});
}
/**
* Registra aplicação de filtros
*/
trackFilterApplied(filters: Record<string, any>): void {
this.queueEvent({
event_type: "filter",
metadata: {
filters,
timestamp: Date.now(),
},
});
}
/**
* Registra favoritar/desfavoritar
*/
trackFavorite(adId: number, action: "favorite" | "unfavorite"): void {
this.queueEvent({
event_type: action,
ad_id: adId,
metadata: {
timestamp: Date.now(),
},
});
}
/**
* Registra compra
*/
trackPurchase(adId: number, price: number): void {
this.queueEvent({
event_type: "purchase",
ad_id: adId,
metadata: {
price,
timestamp: Date.now(),
},
});
// Enviar imediatamente para eventos críticos
this.flushQueue();
}
/**
* Registra inquiry (mensagem ao vendedor)
*/
trackInquiry(adId: number): void {
this.queueEvent({
event_type: "inquiry",
ad_id: adId,
metadata: {
timestamp: Date.now(),
},
});
}
/**
* Registra visualização de página
*/
trackPageView(page: string): void {
this.queueEvent({
event_type: "page_view",
metadata: {
page,
timestamp: Date.now(),
},
});
}
/**
* Registra evento customizado (para eventos que não têm método específico)
*/
trackCustomEvent(
eventType: TelemetryEventType | string,
adId?: number,
metadata?: Record<string, any>
): void {
this.queueEvent({
event_type: eventType as TelemetryEventType,
ad_id: adId,
metadata: {
...metadata,
timestamp: Date.now(),
},
});
}
/**
* Adiciona evento à fila
*/
private queueEvent(event: TelemetryEvent): void {
if (typeof window === "undefined") return;
this.eventQueue.push({
...event,
session_id: this.sessionId,
timestamp: Date.now(),
});
// Enviar imediatamente se a fila estiver cheia
if (this.eventQueue.length >= this.MAX_BATCH_SIZE) {
this.flushQueue();
}
}
/**
* Envia eventos em batch para o servidor
*/
async flushQueue(): Promise<void> {
if (this.eventQueue.length === 0) return;
const eventsToSend = [...this.eventQueue];
this.eventQueue = [];
try {
const response = await fetch("/api/telemetry", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
events: eventsToSend.map(({ timestamp, ...event }) => ({
...event,
session_id: this.sessionId,
})),
}),
});
if (!response.ok) {
// Re-adicionar eventos à fila se falhar (mas não logar erro para não poluir console)
this.eventQueue.unshift(...eventsToSend);
// Silenciar erro para não gerar mensagens no console
}
} catch (error) {
// Re-adicionar eventos à fila se falhar (mas não logar erro)
this.eventQueue.unshift(...eventsToSend);
// Silenciar erro para não gerar mensagens no console
}
}
/**
* Inicia intervalo de envio automático
*/
private startBatchInterval(): void {
if (typeof window === "undefined") return;
this.batchInterval = setInterval(() => {
this.flushQueue();
}, this.BATCH_INTERVAL_MS);
}
/**
* Para o intervalo de envio automático
*/
stop(): void {
if (this.batchInterval) {
clearInterval(this.batchInterval);
this.batchInterval = null;
}
// Enviar eventos pendentes antes de parar
this.flushQueue();
}
/**
* Configura handler para enviar eventos antes de sair da página
*/
private setupPageUnloadHandler(): void {
if (typeof window === "undefined") return;
// Navigator.sendBeacon é mais confiável para page unload
window.addEventListener("beforeunload", () => {
if (this.eventQueue.length === 0) return;
try {
const eventsToSend = this.eventQueue.map(({ timestamp, ...event }) => event);
// Usar sendBeacon para garantir envio
const eventsWithSession = eventsToSend.map(({ timestamp, ...event }) => ({
...event,
session_id: this.sessionId,
}));
// Usar Blob para evitar problemas de message channel
const blob = new Blob(
[JSON.stringify({ events: eventsWithSession })],
{ type: "application/json" }
);
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/telemetry", blob);
}
} catch (error) {
// Silenciar erro para não gerar mensagens no console
}
});
// Fallback para visibilitychange
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
this.flushQueue();
}
});
}
/**
* Atualiza user_id (útil após login)
*/
setUserId(userId: number | undefined): void {
this.userId = userId;
}
}
// Instância singleton
let telemetryInstance: MarketTelemetry | null = null;
/**
* Obtém ou cria instância singleton de telemetria
*/
export function getTelemetry(userId?: number): MarketTelemetry {
if (!telemetryInstance) {
telemetryInstance = new MarketTelemetry(userId);
} else if (userId !== undefined) {
telemetryInstance.setUserId(userId);
}
return telemetryInstance;
}
+91 -4
View File
@@ -250,7 +250,8 @@ export const characterSchema = z.object({
// Schema para criar anúncio (listing)
export const createListingSchema = z.object({
item_id: z.number().int().positive("ID do item deve ser positivo"),
item_id: z.number().int().positive("ID do item deve ser positivo").optional(), // ID legado (SQLite) - opcional
item_id_unified: z.number().int().positive("ID unificado do item deve ser positivo").optional(), // ID unificado da tabela items - preferido
item_name: z
.string()
.min(1, "Nome do item é obrigatório")
@@ -296,13 +297,62 @@ export const createListingSchema = z.object({
.optional()
.nullable()
.transform((val) => val?.trim() || null),
}).refine(
// Campos para troca por item
trade_item_id: z
.number()
.int()
.positive("ID do item de troca deve ser positivo")
.optional()
.nullable(),
trade_item_name: z
.string()
.min(1, "Nome do item de troca é obrigatório se trade_item_id for fornecido")
.max(255, "Nome do item de troca deve ter no máximo 255 caracteres")
.optional()
.nullable()
.transform((name) => name?.trim() || null),
trade_item_quantity: z
.number()
.int()
.positive("Quantidade do item de troca deve ser maior que zero")
.optional()
.nullable()
.default(1),
})
.refine(
(data) => {
// Pelo menos um preço deve ser fornecido
// Pelo menos um ID (item_id ou item_id_unified) deve ser fornecido
if (!data.item_id && !data.item_id_unified) {
return false;
}
return true;
},
{
message: "Pelo menos um ID do item (item_id ou item_id_unified) deve ser fornecido",
path: ["item_id"],
}
)
.refine(
(data) => {
// Se trade_item_id for fornecido, trade_item_name também deve ser
if (data.trade_item_id && !data.trade_item_name) {
return false;
}
return true;
},
{
message: "Nome do item de troca é obrigatório quando ID é fornecido",
path: ["trade_item_name"],
}
)
.refine(
(data) => {
// Pelo menos um preço ou troca por item deve ser fornecido
return (
data.price_pix !== null ||
data.price_tibia_coins !== null ||
data.price_gold_coins !== null
data.price_gold_coins !== null ||
data.trade_item_id !== null
);
},
{
@@ -354,6 +404,41 @@ export const bidSchema = z.object({
.nullable(),
});
// ============================================
// SCHEMAS DE TELEMETRIA
// ============================================
// Schema para evento de telemetria
export const telemetryEventSchema = z.object({
event_type: z.enum([
"ad_view",
"ad_click",
"ad_exit",
"search",
"filter",
"favorite",
"unfavorite",
"purchase",
"purchase_intention_created",
"purchase_intention_viewed",
"bid_placed",
"bid_viewed",
"seller_profile_viewed",
"inquiry",
"page_view",
"price_change",
]),
ad_id: z.number().int().positive().optional().nullable(),
session_id: z.string().optional().nullable(),
metadata: z.any().optional(),
});
// Schema para batch de eventos (inclui session_id no nível do batch)
export const telemetryBatchSchema = z.object({
events: z.array(telemetryEventSchema).min(1).max(100), // Limite de 100 eventos por batch
session_id: z.string().optional().nullable(), // session_id pode estar no nível do batch
});
// Type exports para uso no código
export type CreateDonationInput = z.infer<typeof createDonationSchema>;
export type VerifyStatusInput = z.infer<typeof verifyStatusSchema>;
@@ -365,3 +450,5 @@ export type CharacterInput = z.infer<typeof characterSchema>;
export type CreateListingInput = z.infer<typeof createListingSchema>;
export type UpdateListingInput = z.infer<typeof updateListingSchema>;
export type BidInput = z.infer<typeof bidSchema>;
export type TelemetryEventInput = z.infer<typeof telemetryEventSchema>;
export type TelemetryBatchInput = z.infer<typeof telemetryBatchSchema>;
+13
View File
@@ -8,6 +8,19 @@ const nextConfig: NextConfig = {
formats: ["image/webp", "image/avif"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
remotePatterns: [
{
protocol: "https",
hostname: "www.tibiawiki.com.br",
pathname: "/images/**",
},
],
// Permitir URLs locais com query strings
localPatterns: [
{
pathname: "/api/images/**",
},
],
},
compress: true,
poweredByHeader: false,
+11 -1
View File
@@ -10,7 +10,10 @@
"format": "biome format --write",
"db:migrate": "tsx lib/db/migrations/run-migrations.ts",
"db:seed": "tsx lib/db/seeds/servers-seed.ts",
"db:test": "tsx lib/db/test-connection.ts"
"db:test": "tsx lib/db/test-connection.ts",
"prisma:generate": "prisma generate --schema=prisma/schema.prisma",
"prisma:studio": "prisma studio --schema=prisma/schema.prisma",
"postinstall": "prisma generate --schema=prisma/schema.prisma"
},
"keywords": [
"tibia marketplace",
@@ -24,16 +27,20 @@
"marketplace tibia seguro"
],
"dependencies": {
"@prisma/client": "^6.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.6",
"@types/bcrypt": "^6.0.0",
"@types/canvas-confetti": "^1.9.0",
"@types/jsonwebtoken": "^9.0.10",
@@ -51,6 +58,8 @@
"next-intl": "^4.4.0",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"postcss": "^8.5.6",
"prisma": "^6.18.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-qr-code": "^2.0.18",
@@ -64,6 +73,7 @@
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.90.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+391
View File
@@ -8,12 +8,18 @@ importers:
.:
dependencies:
'@prisma/client':
specifier: ^6.18.0
version: 6.18.0([email protected]([email protected]))([email protected])
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-avatar':
specifier: ^1.1.10
version: 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -29,6 +35,9 @@ importers:
'@radix-ui/react-navigation-menu':
specifier: ^1.2.14
version: 1.2.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-separator':
specifier: ^1.1.7
version: 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -38,6 +47,9 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@tanstack/react-query':
specifier: ^5.90.6
version: 5.90.6([email protected])
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
@@ -89,6 +101,12 @@ importers:
pg:
specifier: ^8.16.3
version: 8.16.3
postcss:
specifier: ^8.5.6
version: 8.5.6
prisma:
specifier: ^6.18.0
version: 6.18.0([email protected])
react:
specifier: 19.1.0
version: 19.1.0
@@ -123,6 +141,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.15
'@tanstack/react-query-devtools':
specifier: ^5.90.2
version: 5.90.2(@tanstack/[email protected]([email protected]))([email protected])
'@types/node':
specifier: ^20
version: 20.19.23
@@ -598,6 +619,39 @@ packages:
cpu: [x64]
os: [win32]
'@prisma/[email protected]':
resolution: {integrity: sha512-jnL2I9gDnPnw4A+4h5SuNn8Gc+1mL1Z79U/3I9eE2gbxJG1oSA+62ByPW4xkeDgwE0fqMzzpAZ7IHxYnLZ4iQA==}
engines: {node: '>=18.18'}
peerDependencies:
prisma: '*'
typescript: '>=5.1.0'
peerDependenciesMeta:
prisma:
optional: true
typescript:
optional: true
'@prisma/[email protected]':
resolution: {integrity: sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==}
'@prisma/[email protected]':
resolution: {integrity: sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==}
'@prisma/engines-version@6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f':
resolution: {integrity: sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==}
'@prisma/[email protected]':
resolution: {integrity: sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==}
'@prisma/[email protected]':
resolution: {integrity: sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==}
'@prisma/[email protected]':
resolution: {integrity: sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==}
'@radix-ui/[email protected]':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/[email protected]':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@@ -640,6 +694,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
@@ -872,6 +939,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
peerDependencies:
@@ -1143,6 +1223,23 @@ packages:
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==}
'@tanstack/[email protected]':
resolution: {integrity: sha512-AnZSLF26R8uX+tqb/ivdrwbVdGemdEDm1Q19qM6pry6eOZ6bEYiY7mWhzXT1YDIPTNEVcZ5kYP9nWjoxDLiIVw==}
'@tanstack/[email protected]':
resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==}
'@tanstack/[email protected]':
resolution: {integrity: sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==}
peerDependencies:
'@tanstack/react-query': ^5.90.2
react: ^18 || ^19
'@tanstack/[email protected]':
resolution: {integrity: sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==}
peerDependencies:
react: ^18 || ^19
'@types/[email protected]':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
@@ -1232,15 +1329,30 @@ packages:
[email protected]:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
[email protected]:
resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
peerDependencies:
magicast: ^0.3.5
peerDependenciesMeta:
magicast:
optional: true
[email protected]:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
[email protected]:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
[email protected]:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
[email protected]:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
[email protected]:
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
[email protected]:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -1258,6 +1370,13 @@ packages:
[email protected]:
resolution: {integrity: sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg==}
[email protected]:
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
[email protected]:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
[email protected]:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -1319,10 +1438,20 @@ packages:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
[email protected]:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'}
[email protected]:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
[email protected]:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1343,6 +1472,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
[email protected]:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
@@ -1350,6 +1483,13 @@ packages:
[email protected]:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
[email protected]:
resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
[email protected]:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
[email protected]:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
@@ -1380,6 +1520,13 @@ packages:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
[email protected]:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
[email protected]:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
@@ -1412,6 +1559,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
[email protected]:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
@@ -1654,20 +1805,37 @@ packages:
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
engines: {node: ^18 || ^20 || >= 21}
[email protected]:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
[email protected]:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
engines: {node: ^14.16.0 || >=16.10.0}
hasBin: true
[email protected]:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
[email protected]:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
[email protected]:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
[email protected]:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
[email protected]:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
[email protected]:
resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==}
@@ -1708,6 +1876,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
[email protected]:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
[email protected]:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -1737,15 +1908,31 @@ packages:
engines: {node: '>=10'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==}
engines: {node: '>=18.18'}
hasBin: true
peerDependencies:
typescript: '>=5.1.0'
peerDependenciesMeta:
typescript:
optional: true
[email protected]:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
[email protected]:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
[email protected]:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
[email protected]:
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
[email protected]:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
[email protected]:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
@@ -1816,6 +2003,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
[email protected]:
resolution: {integrity: sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==}
engines: {node: '>=18'}
@@ -1914,6 +2105,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
[email protected]:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2289,6 +2484,43 @@ snapshots:
'@next/[email protected]':
optional: true
'@prisma/[email protected]([email protected]([email protected]))([email protected])':
optionalDependencies:
prisma: 6.18.0([email protected])
typescript: 5.9.3
'@prisma/[email protected]':
dependencies:
c12: 3.1.0
deepmerge-ts: 7.1.5
effect: 3.18.4
empathic: 2.0.0
transitivePeerDependencies:
- magicast
'@prisma/[email protected]': {}
'@prisma/engines-version@6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f': {}
'@prisma/[email protected]':
dependencies:
'@prisma/debug': 6.18.0
'@prisma/engines-version': 6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f
'@prisma/fetch-engine': 6.18.0
'@prisma/get-platform': 6.18.0
'@prisma/[email protected]':
dependencies:
'@prisma/debug': 6.18.0
'@prisma/engines-version': 6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f
'@prisma/get-platform': 6.18.0
'@prisma/[email protected]':
dependencies:
'@prisma/debug': 6.18.0
'@radix-ui/[email protected]': {}
'@radix-ui/[email protected]': {}
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
@@ -2330,6 +2562,22 @@ snapshots:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-size': 1.1.1(@types/[email protected])([email protected])
react: 19.1.0
react-dom: 19.1.0([email protected])
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -2575,6 +2823,35 @@ snapshots:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-direction': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-focus-guards': 1.1.3(@types/[email protected])([email protected])
'@radix-ui/react-focus-scope': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-popper': 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-portal': 1.1.9(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected])
'@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-visually-hidden': 1.2.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
aria-hidden: 1.2.6
react: 19.1.0
react-dom: 19.1.0([email protected])
react-remove-scroll: 2.7.1(@types/[email protected])([email protected])
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -2790,6 +3067,21 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.15
'@tanstack/[email protected]': {}
'@tanstack/[email protected]': {}
'@tanstack/[email protected](@tanstack/[email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/query-devtools': 5.90.1
'@tanstack/react-query': 5.90.6([email protected])
react: 19.1.0
'@tanstack/[email protected]([email protected])':
dependencies:
'@tanstack/query-core': 5.90.6
react: 19.1.0
'@types/[email protected]':
dependencies:
'@types/node': 20.19.23
@@ -2886,12 +3178,35 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
[email protected]:
dependencies:
chokidar: 4.0.3
confbox: 0.2.2
defu: 6.1.4
dotenv: 16.6.1
exsolve: 1.0.7
giget: 2.0.0
jiti: 2.6.1
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 1.0.0
pkg-types: 2.3.0
rc9: 2.1.2
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
readdirp: 4.1.2
[email protected]: {}
[email protected]:
dependencies:
consola: 3.4.2
[email protected]:
dependencies:
clsx: 2.1.1
@@ -2906,6 +3221,10 @@ snapshots:
dependencies:
phenomenon: 1.6.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -2956,8 +3275,14 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2980,12 +3305,21 @@ snapshots:
domelementtype: 2.3.0
domhandler: 5.0.3
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
safe-buffer: 5.2.1
[email protected]:
dependencies:
'@standard-schema/spec': 1.0.0
fast-check: 3.23.2
[email protected]: {}
[email protected]:
dependencies:
once: 1.4.0
@@ -3034,6 +3368,12 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
pure-rand: 6.1.0
[email protected]: {}
[email protected]([email protected]([email protected]))([email protected]):
@@ -3056,6 +3396,15 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
[email protected]:
dependencies:
citty: 0.1.6
consola: 3.4.2
defu: 6.1.4
node-fetch-native: 1.6.7
nypm: 0.6.2
pathe: 2.0.3
[email protected]: {}
[email protected]: {}
@@ -3261,16 +3610,32 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
citty: 0.1.6
consola: 3.4.2
pathe: 2.0.3
pkg-types: 2.3.0
tinyexec: 1.0.2
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
wrappy: 1.0.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
optional: true
@@ -3310,6 +3675,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
confbox: 0.2.2
exsolve: 1.0.7
pathe: 2.0.3
[email protected]:
dependencies:
nanoid: 3.3.11
@@ -3347,6 +3718,15 @@ snapshots:
tar-fs: 2.1.4
tunnel-agent: 0.6.0
[email protected]([email protected]):
dependencies:
'@prisma/config': 6.18.0
'@prisma/engines': 6.18.0
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- magicast
[email protected]:
dependencies:
loose-envify: 1.4.0
@@ -3358,8 +3738,15 @@ snapshots:
end-of-stream: 1.4.5
once: 1.4.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
defu: 6.1.4
destr: 2.0.5
[email protected]:
dependencies:
deep-extend: 0.6.0
@@ -3426,6 +3813,8 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
[email protected]: {}
[email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]):
dependencies:
'@reduxjs/toolkit': 2.9.2([email protected](@types/[email protected])([email protected])([email protected]))([email protected])
@@ -3555,6 +3944,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
+29
View File
@@ -0,0 +1,29 @@
// Adicionar após o modelo Server no schema.prisma
model Item {
item_id Int @id @default(autoincrement())
source_table_id Int
source_table_name String @db.VarChar(50)
item_name String @db.VarChar(255)
category String @db.VarChar(50)
image_local_path String? @db.Text
image_url String? @db.Text
rarity String? @db.VarChar(20)
level Int?
weight Decimal? @db.Decimal(10, 2)
slots Int?
skill String? @db.VarChar(50)
metadata Json? @default("{}")
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
listings Listing[] @relation("ListingItem")
tradeListings Listing[] @relation("TradeItem")
@@unique([source_table_name, source_table_id])
@@index([item_name])
@@index([category])
@@index([rarity])
@@map("items")
}
+30
View File
@@ -0,0 +1,30 @@
// Prisma Schema para SQLite (Banco de Itens do TibiaWiki)
// URL: DATABASE_URL
// Nota: SQLite no Prisma tem limitações com tabelas dinâmicas
// Usaremos $queryRaw para queries dinâmicas
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/items-client"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// Modelo genérico para itens (usado apenas como referência)
// As tabelas reais são dinâmicas (Capacetes, Armaduras, etc.)
// Todas as queries devem usar $queryRaw para acessar tabelas dinâmicas
model ItemReference {
id Int @id
nome String?
image_url String?
nivel Int?
peso Decimal?
voc String?
@@map("_item_reference")
@@ignore
}
+319
View File
@@ -0,0 +1,319 @@
// Prisma Schema para PostgreSQL (Banco Principal da Aplicação)
// URL: DATABASE_URL_APP
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL_APP")
}
model User {
user_id Int @id @default(autoincrement())
username String @unique @db.VarChar(50)
email String @unique @db.VarChar(255)
password_hash String @db.VarChar(255)
first_name String @db.VarChar(100)
last_name String @db.VarChar(100)
phone String? @db.VarChar(20)
cpf String? @unique @db.VarChar(14)
account_level String @default("free") @db.VarChar(20)
has_donated Boolean @default(false)
whatsapp_notifications Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
last_login DateTime?
is_active Boolean @default(true)
is_verified Boolean @default(false)
characters UserCharacter[]
listings Listing[]
bids Bid[]
transactions_as_buyer Transaction[] @relation("Buyer")
transactions_as_seller Transaction[] @relation("Seller")
notifications Notification[]
inventory UserInventory[]
favorites UserFavorite[]
telemetry_events TelemetryEvent[]
purchaseIntentions PurchaseIntention[]
item Item? @relation("ListingItem", fields: [item_id_unified], references: [item_id], onDelete: SetNull)
tradeItem Item? @relation("TradeItem", fields: [trade_item_id_unified], references: [item_id], onDelete: SetNull) @relation("BuyerIntentions")
@@index([email])
@@index([username])
@@index([cpf])
@@map("users")
}
model UserCharacter {
character_id Int @id @default(autoincrement())
user_id Int
character_name String @db.VarChar(100)
server_name String @db.VarChar(50)
is_main Boolean @default(false)
verified Boolean @default(false)
created_at DateTime @default(now())
user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade)
@@unique([character_name, server_name])
@@index([user_id])
@@map("user_characters")
}
model Server {
server_id Int @id @default(autoincrement())
server_name String @unique @db.VarChar(50)
server_type String? @db.VarChar(20)
location String? @db.VarChar(10)
is_active Boolean @default(true)
total_listings Int @default(0)
total_users Int @default(0)
total_subscribers Int @default(0)
total_transactions Int @default(0)
total_volume Decimal @default(0) @db.Decimal(15, 2)
average_listing_price Decimal? @db.Decimal(10, 2)
last_activity_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
@@index([server_name])
@@index([is_active])
@@index([server_type])
@@map("servers")
}
model Item {
item_id Int @id @default(autoincrement())
source_table_id Int
source_table_name String @db.VarChar(50)
item_name String @db.VarChar(255)
category String @db.VarChar(50)
image_local_path String? @db.Text
image_url String? @db.Text
rarity String? @db.VarChar(20)
level Int?
weight Decimal? @db.Decimal(10, 2)
slots Int?
skill String? @db.VarChar(50)
metadata Json? @default("{}")
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
listings Listing[] @relation("ListingItem")
tradeListings Listing[] @relation("TradeItem")
@@unique([source_table_name, source_table_id])
@@index([item_name])
@@index([category])
@@index([rarity])
@@map("items")
}
model Listing {
listing_id Int @id @default(autoincrement())
seller_id Int
item_id Int // Legacy - manter para compatibilidade
item_id_unified Int? // Nova referência para tabela items
item_name String @db.VarChar(255)
quantity Int @default(1) Int?
min_bid_increment Decimal? @db.Decimal(10, 2)
initial_price_pix Decimal? @db.Decimal(10, 2)
initial_price_tibia_coins Int?
initial_price_gold_coins BigInt?
seller User @relation(fields: [seller_id], references: [user_id], onDelete: Cascade)
bids Bid[]
transactions Transaction[]
analytics AdAnalytics?
telemetry_events TelemetryEvent[]
favorites UserFavorite[]
purchaseIntentions PurchaseIntention[]
item Item? @relation("ListingItem", fields: [item_id_unified], references: [item_id], onDelete: SetNull)
tradeItem Item? @relation("TradeItem", fields: [trade_item_id_unified], references: [item_id], onDelete: SetNull)
@@index([listing_status])
@@index([item_id])
@@index([seller_id])
@@index([server_name])
@@index([created_at(sort: Desc)])
@@index([is_auction, auction_ends_at])
@@index([is_featured])
@@index([expires_at])
@@index([favorites_count(sort: Desc)])
@@map("listings")
}
model Bid {
bid_id Int @id @default(autoincrement())
listing_id Int
bidder_id Int
bid_amount Decimal @db.Decimal(10, 2)
bid_type String? @db.VarChar(20)
created_at DateTime @default(now())
is_active Boolean @default(true)
listing Listing @relation(fields: [listing_id], references: [listing_id], onDelete: Cascade)
bidder User @relation(fields: [bidder_id], references: [user_id], onDelete: Cascade)
@@index([listing_id])
@@index([bidder_id])
@@index([is_active])
@@index([listing_id, bid_amount(sort: Desc)])
@@map("bids")
}
model Transaction {
transaction_id Int @id @default(autoincrement())
listing_id Int?
buyer_id Int
seller_id Int
item_name String @db.VarChar(255)
quantity Int
server_name String @db.VarChar(50)
amount Decimal @db.Decimal(10, 2)
payment_method String? @db.VarChar(20)
escrow_fee Decimal? @db.Decimal(10, 2)
transaction_status String @default("pending") @db.VarChar(30)
created_at DateTime @default(now())
deposited_at DateTime?
delivered_at DateTime?
completed_at DateTime?
buyer_confirmed Boolean @default(false)
seller_confirmed Boolean @default(false)
buyer_notes String? @db.Text
seller_notes String? @db.Text
listing Listing? @relation(fields: [listing_id], references: [listing_id])
buyer User @relation("Buyer", fields: [buyer_id], references: [user_id])
seller User @relation("Seller", fields: [seller_id], references: [user_id])
@@index([buyer_id])
@@index([seller_id])
@@index([transaction_status])
@@index([listing_id])
@@index([created_at(sort: Desc)])
@@map("transactions")
}
model Notification {
notification_id Int @id @default(autoincrement())
user_id Int
notification_type String @db.VarChar(30)
title String @db.VarChar(255)
message String @db.Text
related_id Int?
is_read Boolean @default(false)
send_whatsapp Boolean @default(false)
whatsapp_sent Boolean @default(false)
created_at DateTime @default(now())
user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade)
@@index([user_id, is_read])
@@index([notification_type])
@@index([created_at(sort: Desc)])
@@map("notifications")
}
model TelemetryEvent {
event_id Int @id @default(autoincrement())
event_type String @db.VarChar(50)
ad_id Int?
user_id Int?
session_id String? @db.VarChar(255)
metadata Json? @default("{}")
timestamp DateTime @default(now())
listing Listing? @relation(fields: [ad_id], references: [listing_id], onDelete: Cascade)
user User? @relation(fields: [user_id], references: [user_id], onDelete: SetNull)
@@index([ad_id])
@@index([user_id])
@@index([timestamp(sort: Desc)])
@@index([event_type])
@@index([session_id])
@@index([ad_id, event_type, timestamp(sort: Desc)])
@@map("telemetry_events")
}
model AdAnalytics {
ad_id Int @id
impressions Int @default(0)
clicks Int @default(0)
ctr Decimal @default(0) @db.Decimal(5, 2)
avg_view_duration Decimal @default(0) @db.Decimal(10, 2)
bounce_rate Decimal @default(0) @db.Decimal(5, 2)
inquiries Int @default(0)
favorites Int @default(0)
conversion_rate Decimal @default(0) @db.Decimal(5, 2)
time_to_sell Int?
last_updated DateTime @default(now()) @updatedAt
listing Listing @relation(fields: [ad_id], references: [listing_id], onDelete: Cascade)
@@map("ad_analytics")
}
model UserInventory {
inventory_id Int @id @default(autoincrement())
user_id Int
item_id Int
item_name String @db.VarChar(255)
quantity Int @default(1)
acquired_at DateTime @default(now())
user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade)
@@unique([user_id, item_id])
@@index([user_id])
@@index([item_id])
@@map("user_inventory")
}
model UserFavorite {
user_id Int
ad_id Int
created_at DateTime @default(now())
user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade)
listing Listing @relation(fields: [ad_id], references: [listing_id], onDelete: Cascade)
@@id([user_id, ad_id])
@@index([user_id])
@@index([ad_id])
@@map("user_favorites")
}
model PurchaseIntention {
intention_id Int @id @default(autoincrement())
listing_id Int
buyer_id Int
payment_method String @db.VarChar(20)
status String @default("pending") @db.VarChar(20)
buyer_message String? @db.Text
seller_response String? @db.Text
created_at DateTime @default(now())
updated_at DateTime @updatedAt
expires_at DateTime?
accepted_at DateTime?
rejected_at DateTime?
listing Listing @relation(fields: [listing_id], references: [listing_id], onDelete: Cascade)
buyer User @relation("BuyerIntentions", fields: [buyer_id], references: [user_id], onDelete: Cascade)
@@index([listing_id])
@@index([buyer_id])
@@index([status])
@@index([created_at(sort: Desc)])
@@map("purchase_intentions")
}
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more