Ensure consistent field names and types between API and client components. Add fallback values for missing fields and implement better error logging for invalid items. Improve key generation for item rendering to prevent duplicate keys.
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { ItemCard } from "@/components/item-card";
|
|
import { MarketplaceFilters } from "@/components/marketplace-filters";
|
|
|
|
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, idx) => {
|
|
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 rawCategory = item.category ?? anyItem.category ?? categoryPt;
|
|
const category = normalizeCategory(rawCategory || "other");
|
|
|
|
const idSafe =
|
|
typeof item.id === "string" || typeof item.id === "number"
|
|
? String(item.id)
|
|
: typeof anyItem.item_id === "number"
|
|
? String(anyItem.item_id)
|
|
: typeof anyItem.id === "number"
|
|
? String(anyItem.id)
|
|
: undefined;
|
|
|
|
const name =
|
|
(typeof item.name === "string" && item.name
|
|
? item.name
|
|
: typeof anyItem.item_name === "string" && anyItem.item_name
|
|
? anyItem.item_name
|
|
: typeof anyItem.nome === "string" && anyItem.nome
|
|
? anyItem.nome
|
|
: idSafe) || "Sem nome";
|
|
|
|
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;
|
|
|
|
const sourceTag = [anyItem.source_table_name, anyItem.source_table_id]
|
|
.filter((v) => v !== undefined && v !== null && String(v).length > 0)
|
|
.join(":");
|
|
|
|
const fallbackKey = `${page}-${idx}`;
|
|
const safeKey = [category, idSafe ?? sourceTag ?? name ?? fallbackKey, name]
|
|
.map((v) => String(v || ""))
|
|
.join("::");
|
|
|
|
// Log de integridade de dados
|
|
if (!idSafe && (!name || name === "Sem nome")) {
|
|
console.error("[Library] Item inválido sem id/nome", {
|
|
index: idx,
|
|
page,
|
|
category: rawCategory,
|
|
itemSnapshot: anyItem,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<ItemCard
|
|
key={safeKey}
|
|
item={
|
|
{
|
|
id: (idSafe ?? sourceTag ?? name ?? fallbackKey) as string,
|
|
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
|
|
type="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
|
|
type="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>
|
|
);
|
|
}
|