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,63 @@
|
||||
import path from "node:path";
|
||||
|
||||
// Mapeamento de diretórios PT (filesystem) -> categoria EN (TibiaItem.category)
|
||||
// Ajuste/cubra mais categorias conforme necessário ao longo do uso real
|
||||
export const ptToEnCategoryMap: Record<string, string> = {
|
||||
espadas: "weapon",
|
||||
armaduras: "armor",
|
||||
capacetes: "helmet",
|
||||
calcas: "legs",
|
||||
botas: "boots",
|
||||
escudos: "shield",
|
||||
amuletos: "amulet",
|
||||
aneis: "ring",
|
||||
runas: "rune",
|
||||
ferramentas: "tool",
|
||||
municao: "ammunition",
|
||||
// Outras pastas mapeiam para "other"
|
||||
};
|
||||
|
||||
// Mapeamento inverso EN -> PT para resolver caminho das imagens
|
||||
export const enToPtCategoryMap: Record<string, string> = Object.entries(
|
||||
ptToEnCategoryMap
|
||||
).reduce((acc, [pt, en]) => {
|
||||
acc[en] = pt;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
function normalizeFilenameSegment(value: string): string {
|
||||
// Normaliza para casar melhor com nomes de arquivo: remove acentos e espaços extras
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói um caminho relativo para a imagem do item dentro de data/images.
|
||||
* Retorna um caminho relativo utilizável em fs ou para um handler que sirva arquivos estáticos.
|
||||
*/
|
||||
export function buildItemImagePath(categoryEn: string, fileName: string): string {
|
||||
const ptFolder = enToPtCategoryMap[categoryEn] ?? "outros";
|
||||
// Mantém o nome original caso já esteja correto; fallback para nome normalizado
|
||||
const safeFileName = fileName || "";
|
||||
const normalized = normalizeFilenameSegment(
|
||||
safeFileName.replace(/\.(gif|png|jpg|jpeg|webp)$/i, "")
|
||||
);
|
||||
// Tenta .gif como padrão (ajuste se necessário)
|
||||
const finalFile = safeFileName || `${normalized}.gif`;
|
||||
return path.posix.join("data", "images", ptFolder, finalFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a URL pública para a imagem, caso exista um handler ou static serving configurado.
|
||||
* Por padrão, apontamos para um API proxy que servirá o arquivo do filesystem.
|
||||
*/
|
||||
export function buildPublicItemImageUrl(categoryEn: string, fileName: string): string {
|
||||
const relativePath = buildItemImagePath(categoryEn, fileName);
|
||||
// Ex.: /api/images?path=data/images/espadas/sword.gif
|
||||
return `/api/images?path=${encodeURIComponent(relativePath)}`;
|
||||
}
|
||||
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import path from "node:path";
|
||||
import { enToPtCategoryMap } from "./item-images";
|
||||
|
||||
export interface SearchParams {
|
||||
q?: string;
|
||||
categories?: string[];
|
||||
rarities?: string[];
|
||||
sort?: "name" | "price-asc" | "price-desc" | "rarity" | "recent";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
let schemaLogged = false;
|
||||
|
||||
function getDatabase() {
|
||||
// Carrega better-sqlite3 apenas em runtime para não quebrar o build se não instalado
|
||||
let Database: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
Database = require("better-sqlite3");
|
||||
} catch (err) {
|
||||
// Retorna null para permitir fallback silencioso (API responde vazio ao invés de 500)
|
||||
return null as any;
|
||||
}
|
||||
|
||||
const dbPath = path.join(process.cwd(), "data", "tibiawiki.db");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
return db;
|
||||
}
|
||||
|
||||
// Tenta deduzir ordenação segura a partir do parâmetro de sort
|
||||
function getOrderBy(sort?: SearchParams["sort"]): string {
|
||||
switch (sort) {
|
||||
case "price-asc":
|
||||
return "npc_price ASC, name ASC";
|
||||
case "price-desc":
|
||||
return "npc_price DESC, name ASC";
|
||||
case "rarity":
|
||||
// Requerer que a tabela possua coluna 'rarity'; caso não exista, recai para nome
|
||||
return "rarity ASC, name ASC";
|
||||
case "recent":
|
||||
// Caso exista created_at/updated_at; fallback para name
|
||||
return "updated_at DESC, name ASC";
|
||||
case "name":
|
||||
default:
|
||||
return "name ASC";
|
||||
}
|
||||
}
|
||||
|
||||
// Nota: O schema exato do tibiawiki.db pode variar. Partimos do pressuposto mínimo:
|
||||
// Tabela 'items' com colunas: id, name, category, image, rarity, npc_price
|
||||
// Ajustes poderão ser feitos após validação do schema real.
|
||||
export function searchItems<T = any>(params: SearchParams): SearchResult<T> {
|
||||
const {
|
||||
q = "",
|
||||
categories = [],
|
||||
rarities = [],
|
||||
sort = "name",
|
||||
page = 1,
|
||||
pageSize = 24,
|
||||
} = params;
|
||||
|
||||
const safePage = Math.max(1, page);
|
||||
const safePageSize = Math.min(Math.max(1, pageSize), 50);
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
// Fallback quando better-sqlite3 não está disponível no ambiente
|
||||
return { items: [], total: 0 } as SearchResult<T>;
|
||||
}
|
||||
try {
|
||||
// Log básico do schema na primeira chamada
|
||||
if (!schemaLogged) {
|
||||
try {
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
console.info("[items-db] Tabelas:", tables.map((t) => t.name));
|
||||
for (const t of tables.slice(0, 5)) {
|
||||
try {
|
||||
const cols = db.prepare(`PRAGMA table_info(${t.name})`).all() as any[];
|
||||
console.info(`[items-db] Colunas ${t.name}:`, cols.map((c) => `${c.name}:${c.type}`));
|
||||
const sample = db.prepare(`SELECT * FROM ${t.name} LIMIT 3`).all();
|
||||
console.info(`[items-db] Amostra ${t.name}:`, sample);
|
||||
} catch (inner) {
|
||||
console.warn(`[items-db] Falha ao inspecionar ${t.name}:`, (inner as any)?.message);
|
||||
}
|
||||
}
|
||||
} catch (intErr) {
|
||||
console.warn("[items-db] Falha ao ler schema:", (intErr as any)?.message);
|
||||
} finally {
|
||||
schemaLogged = true;
|
||||
}
|
||||
}
|
||||
|
||||
const where: string[] = [];
|
||||
const bindings: any[] = [];
|
||||
|
||||
if (q) {
|
||||
where.push("LOWER(name) LIKE ?");
|
||||
bindings.push(`%${q.toLowerCase()}%`);
|
||||
}
|
||||
if (categories.length > 0) {
|
||||
const placeholders = categories.map(() => "?").join(",");
|
||||
where.push(`LOWER(category) IN (${placeholders})`);
|
||||
bindings.push(...categories.map((c) => c.toLowerCase()));
|
||||
}
|
||||
if (rarities.length > 0) {
|
||||
const placeholders = rarities.map(() => "?").join(",");
|
||||
where.push(`LOWER(rarity) IN (${placeholders})`);
|
||||
bindings.push(...rarities.map((r) => r.toLowerCase()));
|
||||
}
|
||||
|
||||
const orderBy = getOrderBy(sort);
|
||||
|
||||
// Seleciona de múltiplas tabelas (PT) unificando shape padronizado
|
||||
const allTables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence'")
|
||||
.all() as { name: string }[];
|
||||
// Filtra por categorias solicitadas (EN → PT); caso vazio, usa todas
|
||||
const allowedPt = new Set(
|
||||
(categories.length > 0
|
||||
? categories.map((en) => enToPtCategoryMap[en] || en)
|
||||
: allTables.map((t) => t.name))
|
||||
);
|
||||
const candidateTables = allTables
|
||||
.map((t) => t.name)
|
||||
.filter((name) => allowedPt.has(name));
|
||||
|
||||
const selects: string[] = [];
|
||||
const unionBindings: any[] = [];
|
||||
for (const t of candidateTables) {
|
||||
try {
|
||||
const cols = db.prepare(`PRAGMA table_info(${t})`).all() as any[];
|
||||
const colNames = new Set(cols.map((c) => (c?.name || "").toLowerCase()));
|
||||
// Requer colunas mínimas
|
||||
if (!colNames.has("id") || !colNames.has("nome") || !colNames.has("image_url")) continue;
|
||||
|
||||
const tableWhere: string[] = [];
|
||||
if (q && colNames.has("nome")) {
|
||||
tableWhere.push("LOWER(nome) LIKE ?");
|
||||
unionBindings.push(`%${q.toLowerCase()}%`);
|
||||
}
|
||||
const whereSqlTbl = tableWhere.length ? `WHERE ${tableWhere.join(" AND ")}` : "";
|
||||
// category: derivamos do nome da tabela (pt)
|
||||
const levelCol = colNames.has("nivel") ? "nivel" : "NULL";
|
||||
const weightCol = colNames.has("peso") ? "peso" : "NULL";
|
||||
const slotsCol = colNames.has("slots") ? "slots" : "NULL";
|
||||
const skillCol = colNames.has("skill_para_usar") ? "skill_para_usar" : "NULL";
|
||||
selects.push(
|
||||
`SELECT
|
||||
id,
|
||||
nome AS name,
|
||||
'${t}' AS category,
|
||||
image_url AS image,
|
||||
${levelCol} AS level,
|
||||
${weightCol} AS weight,
|
||||
${slotsCol} AS slots,
|
||||
${skillCol} AS skill
|
||||
FROM ${t} ${whereSqlTbl}`
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`[items-db] Ignorando tabela ${t}:`, (e as any)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (selects.length === 0) {
|
||||
console.warn("[items-db] Nenhuma tabela compatível encontrada");
|
||||
return { items: [], total: 0 } as SearchResult<T>;
|
||||
}
|
||||
|
||||
const unionSql = selects.join(" UNION ALL ");
|
||||
// Contagem total
|
||||
const totalStmt = db.prepare(`SELECT COUNT(1) as cnt FROM (${unionSql}) AS u`);
|
||||
const totalRow = totalStmt.get(...unionBindings) as { cnt: number };
|
||||
const total = totalRow?.cnt ?? 0;
|
||||
|
||||
const selectStmt = db.prepare(
|
||||
`SELECT * FROM (${unionSql}) AS u ORDER BY name ASC LIMIT ? OFFSET ?`
|
||||
);
|
||||
const items = selectStmt.all(...unionBindings, safePageSize, offset) as T[];
|
||||
|
||||
return { items, total };
|
||||
} finally {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user