Files
2025-11-11 03:08:36 -03:00

457 lines
15 KiB
TypeScript

"use client";
import { Grid, List, Search, SortAsc, SortDesc, X } from "lucide-react";
import { useLocale } from "next-intl";
import { useEffect, useState } from "react";
import { AdCard } from "@/components/marketplace/AdCard";
import { CreateAdModal } from "@/components/marketplace/CreateAdModal";
import { MyAdsPanel } from "@/components/marketplace/MyAdsPanel";
import { MyPurchaseIntentionsPanel } from "@/components/marketplace/MyPurchaseIntentionsPanel";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useInvalidateListings, useListings } from "@/lib/hooks/use-listings";
import { useMyPurchaseIntentions } from "@/lib/hooks/use-purchase-intentions";
import { useSession } from "@/lib/hooks/use-session";
import { useMarketTelemetry } from "@/lib/hooks/use-telemetry";
interface Listing {
listing_id: number;
seller_id: number;
item_id: number;
item_name: string;
quantity: number;
server_name: string;
price_pix: number | null;
price_tibia_coins: number | null;
price_gold_coins: number | null;
is_auction: boolean;
description: string | null;
listing_status: string;
created_at: string;
views_count: number;
favorites_count: number;
is_featured?: boolean;
}
interface InitialData {
listings: Listing[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export default function MarketClient({ initial }: { initial: InitialData }) {
const { user } = useSession();
const { trackSearch, trackFilter, trackPageView } = useMarketTelemetry();
const invalidateListings = useInvalidateListings();
const locale = useLocale();
const [page, setPage] = useState<number>(initial.page || 1);
const [pageSize] = useState<number>(initial.pageSize || 20);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
// Filtros
const [searchQuery, setSearchQuery] = useState<string>("");
const [serverName, setServerName] = useState<string>("");
const [sortBy, setSortBy] = useState<string>("created_at");
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
const [priceMin, setPriceMin] = useState<string>("");
const [priceMax, setPriceMax] = useState<string>("");
const [showCreateModal, setShowCreateModal] = useState(false);
const [activeTab, setActiveTab] = useState<string>("explore");
// Usar React Query para buscar listings com atualização automática
const {
listings,
total,
totalPages,
isLoading: loading,
} = useListings({
search: searchQuery || undefined,
server_name: serverName || undefined,
price_min: priceMin || undefined,
price_max: priceMax || undefined,
sortBy,
sortOrder,
page,
pageSize,
});
// Tracking de página
useEffect(() => {
trackPageView("marketplace");
}, [trackPageView]);
const handleSearch = (query: string) => {
setPage(1);
setSearchQuery(query);
if (query) {
trackSearch(query, { server: serverName });
}
};
const handleSort = (newSortBy: string) => {
setPage(1);
if (sortBy === newSortBy) {
// Alternar ordem se já está ordenando por esse campo
setSortOrder(sortOrder === "ASC" ? "DESC" : "ASC");
} else {
setSortBy(newSortBy);
setSortOrder("DESC");
}
};
const handleFilter = (filters: Record<string, any>) => {
setPage(1);
if (filters.server) setServerName(filters.server);
if (filters.priceMin !== undefined) setPriceMin(String(filters.priceMin));
if (filters.priceMax !== undefined) setPriceMax(String(filters.priceMax));
trackFilter(filters);
};
const clearFilters = () => {
setSearchQuery("");
setServerName("");
setPriceMin("");
setPriceMax("");
setPage(1);
};
const sortOptions = [
{ value: "created_at", label: "Mais recentes", icon: SortDesc },
{ value: "price_pix", label: "Menor preço (PIX)", icon: SortAsc },
{ value: "views_count", label: "Mais populares", icon: SortDesc },
];
const activeFiltersCount = [
searchQuery,
serverName,
priceMin,
priceMax,
].filter(Boolean).length;
return (
<div className="space-y-6">
{/* Header com botão de criar anúncio */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Marketplace</h1>
<p className="text-muted-foreground mt-1">
{activeTab === "explore"
? `${total} ${total === 1 ? "anúncio" : "anúncios"} disponíveis`
: "Gerencie seus anúncios"}
</p>
</div>
{user && (
<Button onClick={() => setShowCreateModal(true)}>
Criar Anúncio
</Button>
)}
</div>
{/* Tabs para Explorar, Meus Anúncios e Minhas Intenções */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="explore">Explorar</TabsTrigger>
{user && <TabsTrigger value="my-ads">Meus Anúncios</TabsTrigger>}
{user && <MyPurchaseIntentionsTabTrigger />}
</TabsList>
{/* Aba: Explorar Anúncios */}
<TabsContent value="explore" className="space-y-6 mt-6">
{/* Barra de busca */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar anúncios por item ou descrição..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10 pr-4"
/>
</div>
{/* Filtros e controles */}
<div className="flex flex-wrap items-center gap-4">
{/* Filtro por servidor */}
<Input
placeholder="Filtrar por servidor"
value={serverName}
onChange={(e) => {
setServerName(e.target.value);
handleFilter({ server: e.target.value });
}}
className="w-48"
/>
{/* Filtro por preço mínimo */}
<Input
type="number"
placeholder="Preço min"
value={priceMin}
onChange={(e) => {
setPriceMin(e.target.value);
handleFilter({
priceMin: e.target.value
? parseFloat(e.target.value)
: undefined,
});
}}
className="w-32"
/>
{/* Filtro por preço máximo */}
<Input
type="number"
placeholder="Preço max"
value={priceMax}
onChange={(e) => {
setPriceMax(e.target.value);
handleFilter({
priceMax: e.target.value
? parseFloat(e.target.value)
: undefined,
});
}}
className="w-32"
/>
{/* Ordenação */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
{(() => {
const selectedOption = sortOptions.find(
(opt) => opt.value === sortBy,
);
const Icon = selectedOption?.icon;
return Icon ? <Icon className="h-4 w-4" /> : null;
})()}
{sortOptions.find((opt) => opt.value === sortBy)?.label ||
"Ordenar"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Ordenar por</DropdownMenuLabel>
<DropdownMenuSeparator />
{sortOptions.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuItem
key={option.value}
onClick={() => handleSort(option.value)}
className="flex items-center gap-2"
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
{sortBy === option.value && (
<div className="w-2 h-2 bg-primary rounded-full ml-auto" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Toggle de visualização */}
<div className="flex items-center gap-1 border rounded-md p-1">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
>
<List className="h-4 w-4" />
</Button>
</div>
{/* Limpar filtros */}
{activeFiltersCount > 0 && (
<Button variant="ghost" onClick={clearFilters} className="gap-2">
<X className="h-4 w-4" />
Limpar Filtros
</Button>
)}
</div>
{/* Badges de filtros ativos */}
{activeFiltersCount > 0 && (
<div className="flex flex-wrap gap-2">
{searchQuery && (
<Badge variant="secondary" className="gap-1">
Busca: {searchQuery}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setSearchQuery("");
setPage(1);
}}
/>
</Badge>
)}
{serverName && (
<Badge variant="secondary" className="gap-1">
Servidor: {serverName}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setServerName("");
setPage(1);
}}
/>
</Badge>
)}
{(priceMin || priceMax) && (
<Badge variant="secondary" className="gap-1">
Preço: {priceMin || "0"} - {priceMax || "∞"}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setPriceMin("");
setPriceMax("");
setPage(1);
}}
/>
</Badge>
)}
</div>
)}
{/* Listagem de anúncios */}
{loading ? (
<div
className={
viewMode === "grid"
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-4"
}
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full" />
))}
</div>
) : listings.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
Nenhum anúncio encontrado
</p>
<p className="text-muted-foreground text-sm mt-2">
Tente ajustar os filtros ou criar um novo anúncio
</p>
</div>
) : (
<>
<div
className={
viewMode === "grid"
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-4"
}
>
{listings.map((listing) => (
<AdCard
key={listing.listing_id}
listing={listing}
viewMode={viewMode}
/>
))}
</div>
{/* Paginação */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Anterior
</Button>
<span className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</span>
<Button
variant="outline"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Próxima
</Button>
</div>
)}
</>
)}
{/* Modal de criação de anúncio */}
{showCreateModal && (
<CreateAdModal
open={showCreateModal}
onOpenChange={setShowCreateModal}
onSuccess={() => {
setShowCreateModal(false);
// Invalidar cache para recarregar lista automaticamente
invalidateListings();
// Se estiver na aba explore, mudar para meus anúncios para ver o novo
if (activeTab === "explore") {
setActiveTab("my-ads");
}
}}
/>
)}
</TabsContent>
{/* Aba: Meus Anúncios */}
{user && (
<TabsContent value="my-ads" className="mt-6">
<MyAdsPanel />
</TabsContent>
)}
{/* Aba: Minhas Intenções */}
{user && (
<TabsContent value="my-intentions" className="mt-6">
<MyPurchaseIntentionsPanel locale={locale} />
</TabsContent>
)}
</Tabs>
</div>
);
}
// Componente para o trigger da tab com badge de contador
function MyPurchaseIntentionsTabTrigger() {
const { intentions } = useMyPurchaseIntentions();
const pendingCount = intentions.filter((i) => i.status === "pending").length;
return (
<TabsTrigger value="my-intentions" className="relative">
Minhas Intenções
{pendingCount > 0 && (
<Badge
variant="destructive"
className="ml-2 h-5 min-w-5 px-1.5 text-xs"
>
{pendingCount}
</Badge>
)}
</TabsTrigger>
);
}