feat(item-card): add 'market'/'catalog' mode prop with defensive price checks
Introduces optional `mode` prop ("market"/"catalog") to conditionally render components like rarity badges. Adds defensive checks for missing price data and priceHistory properties to prevent runtime errors. New icons `Badge` and `Boxes` imported from lucide-react.
This commit is contained in:
1 parent
29ba5aece0
commit
44d7f8ce81
21 files changed
+1181
-714
No files matched your search
@@ -0,0 +1,150 @@
|
||||
"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 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);
|
||||
p.set("page", String(page));
|
||||
p.set("pageSize", String(pageSize));
|
||||
return p;
|
||||
}, [query, categories, rarities, sort, page, pageSize]);
|
||||
|
||||
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 } }) => {
|
||||
setPage(1);
|
||||
setCategories(f.category || []);
|
||||
setRarities(f.rarity || []);
|
||||
};
|
||||
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">
|
||||
{items.map((item) => (
|
||||
<ItemCard
|
||||
key={`${String(item.category || "")}::${String(item.id)}`}
|
||||
item={{
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
image: item.image ?? "",
|
||||
category: (item.category as any) ?? "other",
|
||||
rarity: (item.rarity as any) ?? "common",
|
||||
stats: {
|
||||
weight: typeof item.weight === "number" ? item.weight : undefined,
|
||||
slots: typeof item.slots === "number" ? item.slots : undefined,
|
||||
} as any,
|
||||
requirements: {
|
||||
level: typeof item.level === "number" ? item.level : undefined,
|
||||
skill: typeof item.skill === "string" && item.skill !== "Nenhum." ? item.skill : undefined,
|
||||
} as any,
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import Header from "@/components/Header";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import LibraryClient from "./LibraryClient";
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
image?: string;
|
||||
rarity?: string;
|
||||
npcPrice?: number;
|
||||
price?: { buy: number; sell: number };
|
||||
priceHistory?: { highest: number; lowest: number; average: number };
|
||||
stats?: any;
|
||||
requirements?: any;
|
||||
protections?: any;
|
||||
}
|
||||
|
||||
async function fetchItems(params: URLSearchParams) {
|
||||
const qs = params.toString();
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`, {
|
||||
// Em ambiente server, revalidação curta para manter leve
|
||||
next: { revalidate: 15 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { items: [], total: 0, page: 1, pageSize: 24, totalPages: 1 };
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "underConstruction" });
|
||||
return {
|
||||
title: t("market.title"),
|
||||
description: t("market.description"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LibraryPage({ params, searchParams }: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const sp = (await searchParams) ?? {};
|
||||
const initial = new URLSearchParams();
|
||||
if (typeof sp.q === "string") initial.set("q", sp.q);
|
||||
if (typeof sp.sort === "string") initial.set("sort", sp.sort);
|
||||
if (typeof sp.page === "string") initial.set("page", sp.page);
|
||||
if (typeof sp.pageSize === "string") initial.set("pageSize", sp.pageSize);
|
||||
if (typeof sp.category === "string") initial.set("category", sp.category);
|
||||
if (typeof sp.rarity === "string") initial.set("rarity", sp.rarity);
|
||||
|
||||
const data = await fetchItems(initial);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-8 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Biblioteca de Itens</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.total} {data.total === 1 ? "item" : "itens"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LibraryClient initial={data} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const relPath = searchParams.get("path") || "";
|
||||
|
||||
// Permitir apenas caminhos sob data/images para segurança
|
||||
const baseDir = path.join(process.cwd(), "data", "images");
|
||||
const resolved = path.join(process.cwd(), relPath);
|
||||
if (!resolved.startsWith(baseDir)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await fs.promises.readFile(resolved);
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const contentType =
|
||||
ext === ".gif"
|
||||
? "image/gif"
|
||||
: ext === ".png"
|
||||
? "image/png"
|
||||
: ext === ".webp"
|
||||
? "image/webp"
|
||||
: ext === ".jpg" || ext === ".jpeg"
|
||||
? "image/jpeg"
|
||||
: "application/octet-stream";
|
||||
return new Response(file, { status: 200, headers: { "Content-Type": contentType } });
|
||||
} catch {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import path from "node:path";
|
||||
|
||||
function getDb() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Database = require("better-sqlite3");
|
||||
const dbPath = path.join(process.cwd(), "data", "tibiawiki.db");
|
||||
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
try {
|
||||
const db = getDb();
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
db.close();
|
||||
// Retorna nomes das tabelas (PT) para usar como categorias
|
||||
return Response.json({ categories: tables.map((t) => t.name) });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e?.message || "Erro" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { searchItems } from "@/lib/items-db";
|
||||
import { buildPublicItemImageUrl } from "@/lib/item-images";
|
||||
|
||||
function parseArray(param: string | null): string[] {
|
||||
if (!param) return [];
|
||||
// Suporta formatos: comma-separated (a,b) ou múltiplos parâmetros já combinados
|
||||
return param
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = searchParams.get("q") || undefined;
|
||||
const categories = parseArray(searchParams.get("category"));
|
||||
const rarities = parseArray(searchParams.get("rarity"));
|
||||
const sortParam = (searchParams.get("sort") || "name") as
|
||||
| "name"
|
||||
| "price-asc"
|
||||
| "price-desc"
|
||||
| "rarity"
|
||||
| "recent";
|
||||
const page = Number(searchParams.get("page") || "1");
|
||||
const pageSize = Number(searchParams.get("pageSize") || "24");
|
||||
|
||||
const { items, total } = searchItems({
|
||||
q,
|
||||
categories,
|
||||
rarities,
|
||||
sort: sortParam,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
// Enriquecer com URL pública de imagem quando possível
|
||||
const itemsWithImage = items.map((it: any) => {
|
||||
const img = (it.image || "").toString();
|
||||
// Preferir URL absoluta do banco para teste
|
||||
if (/^https?:\/\//i.test(img)) {
|
||||
return { ...it, image: img };
|
||||
}
|
||||
if (img.startsWith("data/")) {
|
||||
return { ...it, image: `/api/images?path=${encodeURIComponent(img)}` };
|
||||
}
|
||||
const categoryEn = (it.category || "other").toString();
|
||||
return { ...it, image: buildPublicItemImageUrl(categoryEn, img) };
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||
return Response.json(
|
||||
{ items: itemsWithImage, page, pageSize, total, totalPages },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("[api/items] Erro:", error?.message, error?.stack);
|
||||
return Response.json(
|
||||
{
|
||||
error: "Falha ao consultar itens",
|
||||
message: error?.message || "Erro desconhecido",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user