diff --git a/app/[locale]/library/LibraryClient.tsx b/app/[locale]/library/LibraryClient.tsx new file mode 100644 index 0000000..ffcb21d --- /dev/null +++ b/app/[locale]/library/LibraryClient.tsx @@ -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(initial.items); + const [total, setTotal] = useState(initial.total); + const [page, setPage] = useState(initial.page || 1); + const [pageSize] = useState(initial.pageSize || 24); + const [totalPages, setTotalPages] = useState(initial.totalPages || 1); + + const [query, setQuery] = useState(""); + const [categories, setCategories] = useState([]); + const [rarities, setRarities] = useState([]); + const [sort, setSort] = useState("name"); + const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[] | undefined>(undefined); + + const debounceRef = useRef(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 ( +
+ + +
+ {items.map((item) => ( + ) + )} +
+ +
+ + Página {page} de {totalPages} + +
+
+ ); +} + + diff --git a/app/[locale]/library/page.tsx b/app/[locale]/library/page.tsx new file mode 100644 index 0000000..dab2182 --- /dev/null +++ b/app/[locale]/library/page.tsx @@ -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>; +}) { + 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 ( +
+
+
+
+

Biblioteca de Itens

+
+ {data.total} {data.total === 1 ? "item" : "itens"} +
+
+ + +
+
+ ); +} + + diff --git a/app/api/images/route.ts b/app/api/images/route.ts new file mode 100644 index 0000000..e25683c --- /dev/null +++ b/app/api/images/route.ts @@ -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 }); + } +} + + diff --git a/app/api/items/categories/route.ts b/app/api/items/categories/route.ts new file mode 100644 index 0000000..f507459 --- /dev/null +++ b/app/api/items/categories/route.ts @@ -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 }); + } +} + + diff --git a/app/api/items/route.ts b/app/api/items/route.ts new file mode 100644 index 0000000..e0f8f6c --- /dev/null +++ b/app/api/items/route.ts @@ -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 } + ); + } +} + + diff --git a/components/item-card.tsx b/components/item-card.tsx index 3a8a125..ba7d96f 100644 --- a/components/item-card.tsx +++ b/components/item-card.tsx @@ -25,6 +25,8 @@ import { Zap as Energy, Skull, Cross, + Badge as BadgeIcon, + Boxes, } from "lucide-react"; import { useState } from "react"; import { @@ -35,9 +37,10 @@ import { interface ItemCardProps { item: TibiaItem; + mode?: "market" | "catalog"; } -export function ItemCard({ item }: ItemCardProps) { +export function ItemCard({ item, mode = "market" }: ItemCardProps) { const t = useTranslations("home.item"); const [isFavorited, setIsFavorited] = useState(false); const [isHovered, setIsHovered] = useState(false); @@ -68,17 +71,27 @@ export function ItemCard({ item }: ItemCardProps) { const CategoryIcon = categoryIcons[item.category]; - // Calcular diferença percentual entre compra e venda - const priceDifference = - ((item.price.buy - item.price.sell) / item.price.sell) * 100; - const isHotDeal = priceDifference > 20; // Hot deal se diferença > 20% + // Calcular diferença percentual entre compra e venda (somente quando houver preço) + const hasPrice = + item && + (item as any).price && + typeof (item as any).price.buy === "number" && + typeof (item as any).price.sell === "number" && + (item as any).price.sell !== 0; + const priceDifference = hasPrice + ? (((item as any).price.buy - (item as any).price.sell) / + (item as any).price.sell) * 100 + : 0; + const isHotDeal = hasPrice && priceDifference > 20; // Hot deal se diferença > 20% // Determinar tendência de preço const getPriceTrend = () => { - if (item.priceHistory.highest > item.priceHistory.average * 1.1) - return "up"; - if (item.priceHistory.lowest < item.priceHistory.average * 0.9) - return "down"; + const ph = (item as any).priceHistory; + if (!ph || typeof ph.highest !== "number" || typeof ph.lowest !== "number" || typeof ph.average !== "number") { + return "stable"; + } + if (ph.highest > ph.average * 1.1) return "up"; + if (ph.lowest < ph.average * 0.9) return "down"; return "stable"; }; @@ -101,18 +114,20 @@ export function ItemCard({ item }: ItemCardProps) { {/* Header com ícone e badge de raridade */}
- {/* Badge de raridade com glow */} - - {item.rarity} - + {/* Badge de raridade com glow (apenas em modo market) */} + {mode === "market" && ( + + {item.rarity} + + )} {/* Botão de favorito */} - {/* Hot Deal Badge */} - {isHotDeal && ( + {/* Hot Deal Badge (apenas em modo market) */} + {mode === "market" && isHotDeal && ( 🔥 Hot Deal )} - {/* Ícone do item */} + {/* Imagem real do item (se disponível) ou ícone por categoria */}
-
- {typeof CategoryIcon === "string" ? ( - CategoryIcon - ) : ( - - )} -
+ {item.image ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( +
+ {typeof CategoryIcon === "string" ? ( + CategoryIcon + ) : ( + + )} +
+ )}
@@ -220,10 +247,17 @@ export function ItemCard({ item }: ItemCardProps) { {/* Requisitos */} {item.requirements && ( -
+
{item.requirements.level && ( -
- 📊 Level {item.requirements.level}+ +
+ + Lvl {item.requirements.level}+ +
+ )} + {(item as any).requirements?.skill && ( +
+ + {(item as any).requirements.skill}
)} {item.requirements.vocation && ( @@ -240,113 +274,120 @@ export function ItemCard({ item }: ItemCardProps) { ⚖️ {item.stats.weight} oz
)} + + {(item as any).stats?.slots && ( +
+ + Slots {(item as any).stats?.slots} +
+ )}
)} - {/* Divisor visual */} -
+ {mode === "market" && item.price && ( + <> + {/* Divisor visual */} +
- {/* Preços com ícones e formatação melhorada */} -
-
-
-
- - {t("buyPrice")} + {/* Preços com ícones e formatação melhorada */} +
+
+
+
+ + {t("buyPrice")} +
+ +
+
+
+ + {t("sellPrice")} +
+ +
- -
-
-
- - {t("sellPrice")} -
- -
-
- {/* Diferença percentual */} -
- 0 ? "text-green-600" : "text-red-600" - }`} + {/* Diferença percentual */} +
+ 0 ? "text-green-600" : "text-red-600" + }`} + > + {priceDifference > 0 ? "+" : ""} + {priceDifference.toFixed(1)}% diferença + +
+
+ + )} + + {mode === "market" && item.priceHistory && ( + <> + {/* Divisor visual */} +
+ + {/* Histórico de preço com sparkline */} +
+
+ + {t("priceHistory")} + +
+ + {trend} +
+
+ +
+
+
+ + {t("highest")} +
+
🪙 {item.priceHistory.highest.toLocaleString()} gp
+
+
+
+ + {t("lowest")} +
+
🪙 {item.priceHistory.lowest.toLocaleString()} gp
+
+
+
+ + {t("average")} +
+
🪙 {item.priceHistory.average.toLocaleString()} gp
+
+
+
+ + )} + + {mode === "market" && ( +
+ +
-
- - {/* Divisor visual */} -
- - {/* Histórico de preço com sparkline */} -
-
- - {t("priceHistory")} - -
- - {trend} -
-
- -
-
-
- - {t("highest")} -
-
- 🪙 {item.priceHistory.highest.toLocaleString()} gp -
-
-
-
- - {t("lowest")} -
-
- 🪙 {item.priceHistory.lowest.toLocaleString()} gp -
-
-
-
- - {t("average")} -
-
- 🪙 {item.priceHistory.average.toLocaleString()} gp -
-
-
-
- - {/* Botões de ação */} -
- - -
+ )}
diff --git a/components/marketplace-filters.tsx b/components/marketplace-filters.tsx index 68f7545..789464e 100644 --- a/components/marketplace-filters.tsx +++ b/components/marketplace-filters.tsx @@ -29,6 +29,8 @@ interface MarketplaceFiltersProps { onFilter: (filters: FilterState) => void; onSort: (sortBy: string) => void; totalItems: number; + showRarity?: boolean; + categoryOptionsOverride?: { value: string; label: string }[]; } interface FilterState { @@ -42,6 +44,8 @@ export function MarketplaceFilters({ onFilter, onSort, totalItems, + showRarity = true, + categoryOptionsOverride, }: MarketplaceFiltersProps) { const t = useTranslations("home"); const [searchQuery, setSearchQuery] = useState(""); @@ -60,7 +64,7 @@ export function MarketplaceFilters({ { value: "legendary", label: "Lendário", color: "bg-orange-500" }, ]; - const categoryOptions = [ + const categoryOptions = categoryOptionsOverride ?? [ { value: "weapon", label: "Arma" }, { value: "armor", label: "Armadura" }, { value: "amulet", label: "Amuleto" }, @@ -142,7 +146,8 @@ export function MarketplaceFilters({ {/* Filtros e ordenação */}
- {/* Filtro por raridade */} + {/* Filtro por raridade (opcional) */} + {showRarity && (