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:
1 parent
55eebde0ba
commit
ab37b477df
106 files changed
+15261
-301
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getListingById,
|
||||
updateListing,
|
||||
deleteListing,
|
||||
} from "@/lib/db/queries/listings";
|
||||
} from "@/lib/prisma/queries/listings";
|
||||
import { updateListingSchema } from "@/lib/validation-schemas";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+391
@@ -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]:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user