Added vocation state and included it in query string builder, updated onFilter to accept a vocation parameter, improved grid layout with grid flow classes, normalized category names for consistent item icons, and refined stats mapping to handle type safety.
259 lines
9.0 KiB
TypeScript
259 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { MarketplaceFilters } from "@/components/marketplace-filters";
|
|
import { ItemCard } from "@/components/item-card";
|
|
|
|
interface Item {
|
|
id: string | number;
|
|
name: string;
|
|
category: string;
|
|
image?: string;
|
|
rarity?: string;
|
|
npcPrice?: number;
|
|
price?: { buy: number; sell: number };
|
|
priceHistory?: { highest: number; lowest: number; average: number };
|
|
level?: number;
|
|
weight?: number;
|
|
slots?: number;
|
|
skill?: string;
|
|
}
|
|
|
|
interface InitialData {
|
|
items: Item[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
totalPages: number;
|
|
}
|
|
|
|
export default function LibraryClient({ initial }: { initial: InitialData }) {
|
|
const [items, setItems] = useState<Item[]>(initial.items);
|
|
const [total, setTotal] = useState<number>(initial.total);
|
|
const [page, setPage] = useState<number>(initial.page || 1);
|
|
const [pageSize] = useState<number>(initial.pageSize || 24);
|
|
const [totalPages, setTotalPages] = useState<number>(initial.totalPages || 1);
|
|
|
|
const [query, setQuery] = useState<string>("");
|
|
const [categories, setCategories] = useState<string[]>([]);
|
|
const [rarities, setRarities] = useState<string[]>([]);
|
|
const [sort, setSort] = useState<string>("name");
|
|
const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[] | undefined>(undefined);
|
|
const [vocation, setVocation] = useState<string | null>(null);
|
|
|
|
const debounceRef = useRef<number | undefined>(undefined);
|
|
|
|
const searchParams = useMemo(() => {
|
|
const p = new URLSearchParams();
|
|
if (query) p.set("q", query);
|
|
if (categories.length > 0) p.set("category", categories.join(","));
|
|
if (rarities.length > 0) p.set("rarity", rarities.join(","));
|
|
if (sort) p.set("sort", sort);
|
|
if (vocation) p.set("voc", vocation);
|
|
p.set("page", String(page));
|
|
p.set("pageSize", String(pageSize));
|
|
return p;
|
|
}, [query, categories, rarities, sort, page, pageSize, vocation]);
|
|
|
|
useEffect(() => {
|
|
// Debounce para evitar chamadas excessivas
|
|
window.clearTimeout(debounceRef.current);
|
|
debounceRef.current = window.setTimeout(async () => {
|
|
const res = await fetch(`/api/items?${searchParams.toString()}`);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
setItems(data.items);
|
|
setTotal(data.total);
|
|
setTotalPages(data.totalPages);
|
|
}, 250);
|
|
return () => window.clearTimeout(debounceRef.current);
|
|
}, [searchParams]);
|
|
|
|
// Carregar categorias dinâmicas a partir das tabelas do banco
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const res = await fetch("/api/items/categories");
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
if (Array.isArray(data.categories)) {
|
|
const opts = (data.categories as string[]).map((c) => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) }));
|
|
setCategoryOptions(opts);
|
|
}
|
|
} catch {
|
|
// no-op
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const onSearch = (q: string) => {
|
|
setPage(1);
|
|
setQuery(q);
|
|
};
|
|
const onFilter = (f: { rarity: string[]; category: string[]; priceRange?: { min: number; max: number }; vocation?: string | null }) => {
|
|
setPage(1);
|
|
setCategories(f.category || []);
|
|
setRarities(f.rarity || []);
|
|
setVocation(f.vocation ?? null);
|
|
};
|
|
const onSort = (s: string) => {
|
|
setPage(1);
|
|
setSort(s);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<MarketplaceFilters onSearch={onSearch} onFilter={onFilter} onSort={onSort} totalItems={total} showRarity={false} categoryOptionsOverride={categoryOptions} />
|
|
|
|
<section className="grid gap-6 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 grid-flow-row-dense">
|
|
{items.map((item) => {
|
|
const anyItem = item as any;
|
|
// Normalização de categoria para os ícones do ItemCard
|
|
const normalizeCategory = (c: string | undefined): string => {
|
|
const v = (c || "").toLowerCase();
|
|
const map: Record<string, string> = {
|
|
escudos: "shield",
|
|
armaduras: "armor",
|
|
capacetes: "helmet",
|
|
calcas: "legs",
|
|
botas: "boots",
|
|
amuletos: "amulet",
|
|
aneis: "ring",
|
|
runas: "rune",
|
|
ferramentas: "tool",
|
|
municao: "ammunition",
|
|
aljavas: "ammunition",
|
|
distancia: "weapon",
|
|
espadas: "weapon",
|
|
machados: "weapon",
|
|
clavas: "weapon",
|
|
punhos: "weapon",
|
|
wands: "weapon",
|
|
rods: "weapon",
|
|
spellbooks: "other",
|
|
chaves: "other",
|
|
comidas: "other",
|
|
decoracoes: "other",
|
|
liquidos: "other",
|
|
plantas: "other",
|
|
recipientes: "other",
|
|
trofeus: "other",
|
|
};
|
|
return map[v] || v || "other";
|
|
};
|
|
|
|
const categoryPt = typeof anyItem.categoria === "string" ? anyItem.categoria : undefined;
|
|
const category = normalizeCategory(item.category ?? categoryPt);
|
|
const name = item.name ?? anyItem.nome ?? String(item.id);
|
|
const image = item.image ?? anyItem.image_url ?? "";
|
|
|
|
// Campos numéricos normalizados
|
|
const level: number | undefined =
|
|
typeof item.level === "number"
|
|
? item.level
|
|
: typeof anyItem.nivel === "number"
|
|
? anyItem.nivel
|
|
: typeof anyItem.lvl === "number"
|
|
? anyItem.lvl
|
|
: undefined;
|
|
|
|
const weight: number | undefined =
|
|
typeof item.weight === "number"
|
|
? item.weight
|
|
: typeof anyItem.peso === "number"
|
|
? anyItem.peso
|
|
: typeof anyItem.peso_em_oz === "number"
|
|
? anyItem.peso_em_oz
|
|
: undefined;
|
|
|
|
const slots: number | undefined =
|
|
typeof item.slots === "number"
|
|
? item.slots
|
|
: typeof anyItem.slots === "number"
|
|
? anyItem.slots
|
|
: undefined;
|
|
|
|
const attack: number | undefined =
|
|
typeof anyItem.ataque === "number"
|
|
? anyItem.ataque
|
|
: typeof anyItem.atq === "number"
|
|
? anyItem.atq
|
|
: undefined;
|
|
const defense: number | undefined =
|
|
typeof anyItem.defesa === "number"
|
|
? anyItem.defesa
|
|
: typeof anyItem.def === "number"
|
|
? anyItem.def
|
|
: undefined;
|
|
const armor: number | undefined =
|
|
typeof anyItem.arm === "number" ? anyItem.arm : undefined;
|
|
|
|
// Skill/requisito para usar
|
|
const skill: string | undefined =
|
|
typeof item.skill === "string" && item.skill !== "Nenhum."
|
|
? item.skill
|
|
: typeof anyItem.skill_para_usar === "string" && anyItem.skill_para_usar !== "Nenhum."
|
|
? anyItem.skill_para_usar
|
|
: undefined;
|
|
const vocation: string | undefined =
|
|
typeof anyItem.voc === "string" && anyItem.voc
|
|
? anyItem.voc
|
|
: undefined;
|
|
|
|
return (
|
|
<ItemCard
|
|
key={`${String((anyItem as any).category)}::${String(item.id)}::${name}`}
|
|
item={{
|
|
id: String(item.id),
|
|
name,
|
|
image,
|
|
category: category as any,
|
|
rarity: (item.rarity as any) ?? "common",
|
|
categoryPt,
|
|
stats: {
|
|
weight,
|
|
slots,
|
|
attack,
|
|
defense,
|
|
armor,
|
|
} as any,
|
|
requirements: {
|
|
level,
|
|
skill,
|
|
vocation,
|
|
} as any,
|
|
// Campos específicos de aljavas (e reutilizáveis para outras categorias)
|
|
volume: typeof anyItem.volume === "number" ? anyItem.volume : undefined,
|
|
bonus_de_skill: typeof anyItem.bonus_de_skill === "number" ? anyItem.bonus_de_skill : undefined,
|
|
protecao: typeof anyItem.protecao === "string" ? anyItem.protecao : undefined,
|
|
dropa_de: typeof anyItem.dropa_de === "string" ? anyItem.dropa_de : undefined,
|
|
} as any}
|
|
mode="catalog"
|
|
/>
|
|
);
|
|
})}
|
|
</section>
|
|
|
|
<div className="flex items-center justify-center gap-2">
|
|
<button
|
|
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
|
|
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
|
|
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages}
|
|
>
|
|
Próxima
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|