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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user