Merge pull request #4 from evandrodevbr:main

Main
This commit is contained in:
Evandro Fonseca Junior authored and GitHub committed 2025-10-30 18:50:25 -03:00
commit 1671e4df58
2597 files changed
+5041 -169

No files matched your search

+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/iconScript/data
/data/images
+258
View File
@@ -0,0 +1,258 @@
"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>
);
}
+73
View File
@@ -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>
);
}
+35
View File
@@ -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 });
}
}
+25
View File
@@ -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 });
}
}
+72
View File
@@ -0,0 +1,72 @@
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"
| "level-asc"
| "level-desc"
| "weight-asc"
| "weight-desc"
| "category";
const page = Number(searchParams.get("page") || "1");
const pageSize = Number(searchParams.get("pageSize") || "24");
const voc = searchParams.get("voc") || undefined;
const { items, total } = searchItems({
q,
categories,
rarities,
sort: sortParam,
vocation: voc,
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 }
);
}
}
+1
View File
@@ -20,6 +20,7 @@ export default function Header() {
const locale = useLocale(); const locale = useLocale();
const links = [ const links = [
{ href: `/${locale}/library`, label: "Library" },
{ href: `/${locale}/market`, label: t("market") }, { href: `/${locale}/market`, label: t("market") },
{ href: `/${locale}/sobre`, label: t("about") }, { href: `/${locale}/sobre`, label: t("about") },
{ href: `/${locale}/precos`, label: t("prices") }, { href: `/${locale}/precos`, label: t("prices") },
+519 -158
View File
@@ -25,8 +25,15 @@ import {
Zap as Energy, Zap as Energy,
Skull, Skull,
Cross, Cross,
Badge as BadgeIcon,
Boxes,
Gem,
FlaskConical,
Wrench,
ScrollText,
Leaf,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
getRarityColor, getRarityColor,
getRarityBgColor, getRarityBgColor,
@@ -35,12 +42,177 @@ import {
interface ItemCardProps { interface ItemCardProps {
item: TibiaItem; item: TibiaItem;
mode?: "market" | "catalog";
} }
export function ItemCard({ item }: ItemCardProps) { export function ItemCard({ item, mode = "market" }: ItemCardProps) {
const t = useTranslations("home.item"); const t = useTranslations("home.item");
const [isFavorited, setIsFavorited] = useState(false); const [isFavorited, setIsFavorited] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const formatVocation = (v: string | undefined): string | undefined => {
if (!v) return undefined;
let out = String(v);
// Inserir espaço quando 'and' vier colado em palavras
out = out.replace(/([A-Za-z])and([A-Za-z])/g, "$1 and $2");
// Normalizar vírgulas e conectivos
out = out.replace(/,/g, ", ");
out = out.replace(/\s*and\s*/gi, " and ");
// Colapsar espaços múltiplos
out = out.replace(/\s+/g, " ").trim();
return out;
};
const ScrollRow = ({ children, ariaLabel }: { children: React.ReactNode; ariaLabel: string }) => {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const autoScrollRef = useRef<number | null>(null);
const drag = useRef<{ active: boolean; startX: number; startLeft: number }>({ active: false, startX: 0, startLeft: 0 });
const [isDragging, setIsDragging] = useState(false);
const updateButtons = () => {
const el = scrollerRef.current;
if (!el) return;
const base = contentRef.current?.offsetWidth || 0;
const hasOverflow = el.scrollWidth > el.clientWidth + 2 && base > 0;
// Em loop infinito, ambos os lados ficam disponíveis quando há overflow
setCanScrollLeft(hasOverflow);
setCanScrollRight(hasOverflow);
};
useEffect(() => {
// Inject global CSS once to hide scrollbars cross-browser
const styleId = "no-scrollbar-styles";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.no-scrollbar::-webkit-scrollbar { display: none; height: 0; width: 0; }
`;
document.head.appendChild(style);
}
updateButtons();
const el = scrollerRef.current;
if (!el) return;
const handler = () => updateButtons();
el.addEventListener("scroll", handler);
const ro = new ResizeObserver(() => updateButtons());
ro.observe(el);
const onEnter = () => setIsHovering(true);
const onLeave = () => setIsHovering(false);
el.addEventListener("mouseenter", onEnter);
el.addEventListener("mouseleave", onLeave);
return () => {
el.removeEventListener("scroll", handler);
el.removeEventListener("mouseenter", onEnter);
el.removeEventListener("mouseleave", onLeave);
ro.disconnect();
};
}, []);
const scrollBy = (dx: number) => {
const el = scrollerRef.current;
if (!el) return;
const base = contentRef.current?.offsetWidth || 0;
if (base > 0) {
let next = el.scrollLeft + dx;
if (next < 0) next += base;
if (next >= base) next -= base;
el.scrollTo({ left: next, behavior: "smooth" });
} else {
el.scrollBy({ left: dx, behavior: "smooth" });
}
};
// Auto-scroll when not hovering and there's overflow
useEffect(() => {
const tick = () => {
const el = scrollerRef.current;
if (!el) return;
const base = contentRef.current?.offsetWidth || 0;
const hasOverflow = el.scrollWidth > el.clientWidth + 2 && base > 0;
if (!isHovering && hasOverflow) {
// velocidade ajustada para ~36px/s @60fps
el.scrollLeft += 0.6;
if (base > 0 && el.scrollLeft >= base) {
// wrap sem salto perceptível
el.scrollLeft -= base;
}
}
autoScrollRef.current = window.requestAnimationFrame(tick);
};
autoScrollRef.current = window.requestAnimationFrame(tick);
return () => {
if (autoScrollRef.current) cancelAnimationFrame(autoScrollRef.current);
autoScrollRef.current = null;
};
}, [isHovering]);
// Drag to scroll (pointer events)
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0) return; // only left click/touch
drag.current.active = true;
drag.current.startX = e.clientX;
drag.current.startLeft = el.scrollLeft;
setIsDragging(true);
el.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!drag.current.active) return;
const dx = e.clientX - drag.current.startX;
const base = contentRef.current?.offsetWidth || 0;
let next = drag.current.startLeft - dx;
if (base > 0) {
if (next < 0) next += base;
if (next >= base) next -= base;
}
el.scrollLeft = next;
};
const onPointerUp = (e: PointerEvent) => {
if (!drag.current.active) return;
drag.current.active = false;
setIsDragging(false);
try { el.releasePointerCapture(e.pointerId); } catch {}
};
el.addEventListener("pointerdown", onPointerDown);
el.addEventListener("pointermove", onPointerMove);
el.addEventListener("pointerup", onPointerUp);
el.addEventListener("pointercancel", onPointerUp);
return () => {
el.removeEventListener("pointerdown", onPointerDown);
el.removeEventListener("pointermove", onPointerMove);
el.removeEventListener("pointerup", onPointerUp);
el.removeEventListener("pointercancel", onPointerUp);
};
}, []);
return (
<div className="relative">
{/* botões removidos conforme solicitado */}
<div
ref={scrollerRef}
className={`overflow-x-auto no-scrollbar whitespace-nowrap pr-6 pl-6 select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
style={{ scrollBehavior: "smooth" }}
>
<div ref={contentRef} className="inline-flex items-center gap-2">
{children}
{children}
</div>
</div>
</div>
);
};
const [showAllDroppers, setShowAllDroppers] = useState(false);
const rarityColors = { const rarityColors = {
common: "bg-gray-500", common: "bg-gray-500",
@@ -57,28 +229,38 @@ export function ItemCard({ item }: ItemCardProps) {
legs: Shield, legs: Shield,
boots: Shield, boots: Shield,
shield: Shield, shield: Shield,
amulet: "💎", amulet: Gem,
ring: "💍", ring: Gem,
potion: "🧪", potion: FlaskConical,
rune: "📜", rune: ScrollText,
tool: "🔧", tool: Wrench,
ammunition: "🏹", ammunition: Boxes,
other: "📦", other: Boxes,
}; } as const;
const CategoryIcon = categoryIcons[item.category]; const CategoryIcon = categoryIcons[item.category];
// Calcular diferença percentual entre compra e venda // Calcular diferença percentual entre compra e venda (somente quando houver preço)
const priceDifference = const hasPrice =
((item.price.buy - item.price.sell) / item.price.sell) * 100; item &&
const isHotDeal = priceDifference > 20; // Hot deal se diferença > 20% (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 // Determinar tendência de preço
const getPriceTrend = () => { const getPriceTrend = () => {
if (item.priceHistory.highest > item.priceHistory.average * 1.1) const ph = (item as any).priceHistory;
return "up"; if (!ph || typeof ph.highest !== "number" || typeof ph.lowest !== "number" || typeof ph.average !== "number") {
if (item.priceHistory.lowest < item.priceHistory.average * 0.9) return "stable";
return "down"; }
if (ph.highest > ph.average * 1.1) return "up";
if (ph.lowest < ph.average * 0.9) return "down";
return "stable"; return "stable";
}; };
@@ -94,25 +276,27 @@ export function ItemCard({ item }: ItemCardProps) {
return ( return (
<div <div
className="group relative h-full" className="group relative"
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<MagicCard className="p-0 overflow-hidden h-full flex flex-col rounded-xl border border-border/50 hover:border-primary/30 transition-all duration-300 hover:scale-[1.02] hover:shadow-xl"> <MagicCard className="p-0 overflow-hidden rounded-xl border border-border/50 hover:border-primary/30 transition-all duration-300 hover:scale-[1.02] hover:shadow-xl">
{/* Header com ícone e badge de raridade */} {/* Header com ícone e badge de raridade */}
<div className="relative aspect-square bg-linear-to-br from-background to-muted p-6"> <div className="relative aspect-square bg-linear-to-br from-background to-muted p-6">
{/* Badge de raridade com glow */} {/* Badge de raridade com glow (apenas em modo market) */}
<Badge {mode === "market" && (
className={`${ <Badge
rarityColors[item.rarity] className={`${
} text-white absolute top-3 left-3 z-20 shadow-lg ${getRarityGlow( rarityColors[item.rarity]
item.rarity } text-white absolute top-3 left-3 z-20 shadow-lg ${getRarityGlow(
)} ${item.rarity === "legendary" ? "animate-pulse" : ""} ${ item.rarity
item.rarity === "epic" ? "animate-pulse" : "" )} ${item.rarity === "legendary" ? "animate-pulse" : ""} ${
}`} item.rarity === "epic" ? "animate-pulse" : ""
> }`}
{item.rarity} >
</Badge> {item.rarity}
</Badge>
)}
{/* Botão de favorito */} {/* Botão de favorito */}
<Button <Button
@@ -130,22 +314,35 @@ export function ItemCard({ item }: ItemCardProps) {
/> />
</Button> </Button>
{/* Hot Deal Badge */} {/* Hot Deal Badge (apenas em modo market) */}
{isHotDeal && ( {mode === "market" && isHotDeal && (
<Badge className="absolute top-3 left-1/2 transform -translate-x-1/2 z-20 bg-red-500 text-white shadow-lg animate-pulse"> <Badge className="absolute top-3 left-1/2 transform -translate-x-1/2 z-20 bg-red-500 text-white shadow-lg animate-pulse flex items-center gap-1">
🔥 Hot Deal <Flame className="h-3 w-3" />
Hot Deal
</Badge> </Badge>
)} )}
{/* Ícone do item */} {/* Imagem real do item (se disponível) ou ícone por categoria */}
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm"> {item.image ? (
{typeof CategoryIcon === "string" ? ( // eslint-disable-next-line @next/next/no-img-element
CategoryIcon <img
) : ( src={item.image}
<CategoryIcon className="w-10 h-10" /> alt={item.name}
)} className="w-20 h-20 object-contain drop-shadow-sm"
</div> onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
{typeof CategoryIcon === "string" ? (
null
) : (
<CategoryIcon className="w-10 h-10" />
)}
</div>
)}
</div> </div>
</div> </div>
@@ -162,6 +359,26 @@ export function ItemCard({ item }: ItemCardProps) {
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
<span>Atualizado há 5 min</span> <span>Atualizado há 5 min</span>
</div> </div>
{/* Meta: ID e Categoria */}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{(() => {
const catPt = (item as any).categoryPt as string | undefined;
const catEn = (item as any).category as string | undefined;
const catRaw = (catPt && String(catPt)) || (catEn && String(catEn)) || "";
const display = catRaw
? catRaw.charAt(0).toUpperCase() + catRaw.slice(1)
: "";
return display ? (
<span className="px-1.5 py-0.5 rounded bg-muted/60 text-foreground/80">
{display}
</span>
) : null;
})()}
{typeof item.id === "string" && item.id && (
<span className="opacity-80">ID: {item.id}</span>
)}
</div>
</div> </div>
{/* Estatísticas agrupadas */} {/* Estatísticas agrupadas */}
@@ -172,7 +389,7 @@ export function ItemCard({ item }: ItemCardProps) {
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Sword className="h-4 w-4 text-primary" /> <Sword className="h-4 w-4 text-primary" />
<span className="font-medium"> <span className="font-medium">
⚔️ {item.stats.attack} ATK {item.stats.attack} ATK
</span> </span>
</div> </div>
)} )}
@@ -180,7 +397,7 @@ export function ItemCard({ item }: ItemCardProps) {
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Shield className="h-4 w-4 text-primary" /> <Shield className="h-4 w-4 text-primary" />
<span className="font-medium"> <span className="font-medium">
🛡️ {item.stats.defense} DEF {item.stats.defense} DEF
</span> </span>
</div> </div>
)} )}
@@ -188,165 +405,309 @@ export function ItemCard({ item }: ItemCardProps) {
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Shield className="h-4 w-4 text-primary" /> <Shield className="h-4 w-4 text-primary" />
<span className="font-medium"> <span className="font-medium">
🛡️ {item.stats.armor} ARM {item.stats.armor} ARM
</span> </span>
</div> </div>
)} )}
{(item as any).volume && (
<div className="flex items-center gap-1.5 text-sm">
<Boxes className="h-4 w-4 text-primary" />
<span className="font-medium">Volume {(item as any).volume}</span>
</div>
)}
{(item as any).bonus_de_skill && (
<div className="flex items-center gap-1.5 text-sm">
<Zap className="h-4 w-4 text-primary" />
<span className="font-medium">Skill +{(item as any).bonus_de_skill}</span>
</div>
)}
</div> </div>
{/* Proteções elementais */} {/* Proteções elementais */}
{item.protections && ( {item.protections && (
<div className="flex flex-wrap gap-2"> <ScrollRow ariaLabel="protections">
{item.protections.fire && ( {item.protections.fire && (
<div className="flex items-center gap-1 text-xs bg-red-500/10 text-red-600 px-2 py-1 rounded"> <div className="flex items-center gap-1 text-xs bg-red-500/10 text-red-600 px-2 py-1 rounded">
<Flame className="h-3 w-3" /> <Flame className="h-3 w-3" />
<span>🔥 Fire +{item.protections.fire}%</span> <span>Fire +{item.protections.fire}%</span>
</div> </div>
)} )}
{item.protections.ice && ( {item.protections.ice && (
<div className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-600 px-2 py-1 rounded"> <div className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-600 px-2 py-1 rounded">
<Snowflake className="h-3 w-3" /> <Snowflake className="h-3 w-3" />
<span>❄️ Ice +{item.protections.ice}%</span> <span>Ice +{item.protections.ice}%</span>
</div> </div>
)} )}
{item.protections.energy && ( {item.protections.energy && (
<div className="flex items-center gap-1 text-xs bg-yellow-500/10 text-yellow-600 px-2 py-1 rounded"> <div className="flex items-center gap-1 text-xs bg-yellow-500/10 text-yellow-600 px-2 py-1 rounded">
<Energy className="h-3 w-3" /> <Energy className="h-3 w-3" />
<span>⚡ Energy +{item.protections.energy}%</span> <span>Energy +{item.protections.energy}%</span>
</div> </div>
)} )}
</div> </ScrollRow>
)} )}
{/* Requisitos */} {/* Requisitos */}
{item.requirements && ( {item.requirements && (
<div className="space-y-1"> <div className="flex flex-wrap gap-2">
{item.requirements.level && ( {typeof (item as any).requirements?.level === "number" && (
<div className="text-xs text-muted-foreground"> <div className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-1 rounded">
📊 Level {item.requirements.level}+ <Star className="h-3 w-3" />
<span>Lvl {(item as any).requirements.level}+</span>
</div>
)}
{(item as any).requirements?.skill && (
<div className="flex items-center gap-1 text-xs bg-indigo-500/10 text-indigo-600 px-2 py-1 rounded">
<Zap className="h-3 w-3" />
<span>{(item as any).requirements.skill}</span>
</div> </div>
)} )}
{item.requirements.vocation && ( {item.requirements.vocation && (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
👤 Best for {item.requirements.vocation} Best for {formatVocation(item.requirements.vocation)}
</div> </div>
)} )}
</div> </div>
)} )}
{(item as any).protecao && (() => {
const raw = String((item as any).protecao || "");
const tokens = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.filter((s) => !/^nenhum(a)?\.?$/i.test(s.trim()));
if (tokens.length === 0) return null;
return (
<ScrollRow ariaLabel="elemental protections">
{tokens.map((raw: string, idx: number) => {
const token = raw.trim();
const match = token.match(/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/);
const type = (match?.[1] || token).trim().toLowerCase();
const value = (match?.[2] || "").trim();
let Icon: any = Shield;
let chipBg = "bg-amber-500/10";
let chipText = "text-amber-700";
switch (type) {
case "fire":
case "fogo":
Icon = Flame;
chipBg = "bg-red-500/10";
chipText = "text-red-600";
break;
case "ice":
case "gelo":
Icon = Snowflake;
chipBg = "bg-blue-500/10";
chipText = "text-blue-600";
break;
case "energy":
case "energia":
Icon = Energy;
chipBg = "bg-yellow-500/10";
chipText = "text-yellow-600";
break;
case "earth":
case "terra":
Icon = Leaf;
chipBg = "bg-green-500/10";
chipText = "text-green-600";
break;
case "death":
case "morte":
Icon = Skull;
chipBg = "bg-zinc-500/10";
chipText = "text-zinc-600";
break;
case "holy":
case "sagrado":
Icon = Cross;
chipBg = "bg-amber-500/10";
chipText = "text-amber-700";
break;
case "physical":
case "fisico":
Icon = Shield;
chipBg = "bg-slate-500/10";
chipText = "text-slate-600";
break;
default:
Icon = Shield;
chipBg = "bg-amber-500/10";
chipText = "text-amber-700";
}
const prettyType = type.charAt(0).toUpperCase() + type.slice(1);
const label = `${prettyType.replace("fisico", "Físico")}${value ? " " + value : ""}`;
return (
<div key={`${type}-${idx}`} className={`flex items-center gap-1 text-xs ${chipBg} ${chipText} px-2 py-1 rounded`}>
<Icon className="h-3 w-3" />
<span className="whitespace-nowrap">{label}</span>
</div>
);
})}
</ScrollRow>
);
})()}
{item.stats.weight && ( {item.stats.weight && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Weight className="h-4 w-4" /> <Weight className="h-4 w-4" />
<span>⚖️ {item.stats.weight} oz</span> <span>{item.stats.weight} oz</span>
</div>
)}
{(item as any).stats?.slots && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Boxes className="h-3 w-3" />
<span>Slots {(item as any).stats?.slots}</span>
</div> </div>
)} )}
</div> </div>
)} )}
{/* Divisor visual */} {(item as any).dropa_de && (
<div className="border-t border-border/50" /> (() => {
const raw = String((item as any).dropa_de || "");
{/* Preços com ícones e formatação melhorada */} const normalized = raw.trim();
<div className="space-y-3"> // Casos especiais de "nenhum/ninguém"
<div className="grid grid-cols-2 gap-4"> const none = /^(nenhum|nenhuma|ningu[eé]m)\.?$/i.test(normalized);
<div className="space-y-1"> if (none) {
<div className="flex items-center gap-1 text-xs text-muted-foreground"> return (
<Coins className="h-3 w-3" /> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{t("buyPrice")}</span> <Skull className="h-3 w-3" />
<span>Dropa de: Ninguém</span>
</div>
);
}
const droppers = normalized
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return (
<div className="text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground mb-1">
<Skull className="h-3 w-3" />
<span>Dropa de:</span>
</div>
<ScrollRow ariaLabel="droppers">
{droppers.map((name, idx) => (
<span
key={`dropper-${idx}-${name}`}
className="px-2 py-0.5 rounded bg-muted/60 text-foreground/80 whitespace-nowrap"
>
{name}
</span>
))}
</ScrollRow>
</div> </div>
<PriceDisplay );
value={item.price.buy} })()
className="text-green-600 dark:text-green-400" )}
size="md"
/>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Coins className="h-3 w-3" />
<span>{t("sellPrice")}</span>
</div>
<PriceDisplay
value={item.price.sell}
className="text-red-600 dark:text-red-400"
size="md"
/>
</div>
</div>
{/* Diferença percentual */} {mode === "market" && item.price && (
<div className="text-center"> <>
<span {/* Divisor visual */}
className={`text-sm font-medium ${ <div className="border-t border-border/50" />
priceDifference > 0 ? "text-green-600" : "text-red-600"
}`} {/* Preços com ícones e formatação melhorada */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Coins className="h-3 w-3" />
<span>{t("buyPrice")}</span>
</div>
<PriceDisplay
value={item.price.buy}
className="text-green-600 dark:text-green-400"
size="md"
/>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Coins className="h-3 w-3" />
<span>{t("sellPrice")}</span>
</div>
<PriceDisplay
value={item.price.sell}
className="text-red-600 dark:text-red-400"
size="md"
/>
</div>
</div>
{/* Diferença percentual */}
<div className="text-center">
<span
className={`text-sm font-medium ${
priceDifference > 0 ? "text-green-600" : "text-red-600"
}`}
>
{priceDifference > 0 ? "+" : ""}
{priceDifference.toFixed(1)}% diferença
</span>
</div>
</div>
</>
)}
{mode === "market" && item.priceHistory && (
<>
{/* Divisor visual */}
<div className="border-t border-border/50" />
{/* Histórico de preço com sparkline */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
{t("priceHistory")}
</span>
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
<TrendIcon className="h-3 w-3" />
<span className="capitalize">{trend}</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-green-600">
<TrendingUp className="h-3 w-3" />
<span className="font-medium">{t("highest")}</span>
</div>
<div className="font-bold">{item.priceHistory.highest.toLocaleString()} gp</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-red-600">
<TrendingDown className="h-3 w-3" />
<span className="font-medium">{t("lowest")}</span>
</div>
<div className="font-bold">{item.priceHistory.lowest.toLocaleString()} gp</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
<span className="font-medium">{t("average")}</span>
</div>
<div className="font-bold">{item.priceHistory.average.toLocaleString()} gp</div>
</div>
</div>
</div>
</>
)}
{mode === "market" && (
<div className="pt-4 space-y-2">
<Button
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
variant="outline"
> >
{priceDifference > 0 ? "+" : ""} {t("viewDetails")}
{priceDifference.toFixed(1)}% diferença </Button>
</span> <Button className="w-full bg-green-600 hover:bg-green-700 text-white" size="sm">
{t("buyNow")}
</Button>
</div> </div>
</div> )}
{/* Divisor visual */}
<div className="border-t border-border/50" />
{/* Histórico de preço com sparkline */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
{t("priceHistory")}
</span>
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
<TrendIcon className="h-3 w-3" />
<span className="capitalize">{trend}</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-green-600">
<TrendingUp className="h-3 w-3" />
<span className="font-medium">{t("highest")}</span>
</div>
<div className="font-bold">
🪙 {item.priceHistory.highest.toLocaleString()} gp
</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-red-600">
<TrendingDown className="h-3 w-3" />
<span className="font-medium">{t("lowest")}</span>
</div>
<div className="font-bold">
🪙 {item.priceHistory.lowest.toLocaleString()} gp
</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
<span className="font-medium">{t("average")}</span>
</div>
<div className="font-bold">
🪙 {item.priceHistory.average.toLocaleString()} gp
</div>
</div>
</div>
</div>
{/* Botões de ação */}
<div className="pt-4 space-y-2">
<Button
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
variant="outline"
>
{t("viewDetails")}
</Button>
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
size="sm"
>
{t("buyNow")}
</Button>
</div>
</div> </div>
</MagicCard> </MagicCard>
</div> </div>
+82 -9
View File
@@ -29,12 +29,15 @@ interface MarketplaceFiltersProps {
onFilter: (filters: FilterState) => void; onFilter: (filters: FilterState) => void;
onSort: (sortBy: string) => void; onSort: (sortBy: string) => void;
totalItems: number; totalItems: number;
showRarity?: boolean;
categoryOptionsOverride?: { value: string; label: string }[];
} }
interface FilterState { interface FilterState {
rarity: string[]; rarity: string[];
category: string[]; category: string[];
priceRange: { min: number; max: number }; priceRange: { min: number; max: number };
vocation?: string | null;
} }
export function MarketplaceFilters({ export function MarketplaceFilters({
@@ -42,6 +45,8 @@ export function MarketplaceFilters({
onFilter, onFilter,
onSort, onSort,
totalItems, totalItems,
showRarity = true,
categoryOptionsOverride,
}: MarketplaceFiltersProps) { }: MarketplaceFiltersProps) {
const t = useTranslations("home"); const t = useTranslations("home");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -49,6 +54,7 @@ export function MarketplaceFilters({
rarity: [], rarity: [],
category: [], category: [],
priceRange: { min: 0, max: 100000 }, priceRange: { min: 0, max: 100000 },
vocation: null,
}); });
const [sortBy, setSortBy] = useState("name"); const [sortBy, setSortBy] = useState("name");
@@ -60,7 +66,7 @@ export function MarketplaceFilters({
{ value: "legendary", label: "Lendário", color: "bg-orange-500" }, { value: "legendary", label: "Lendário", color: "bg-orange-500" },
]; ];
const categoryOptions = [ const categoryOptions = categoryOptionsOverride ?? [
{ value: "weapon", label: "Arma" }, { value: "weapon", label: "Arma" },
{ value: "armor", label: "Armadura" }, { value: "armor", label: "Armadura" },
{ value: "amulet", label: "Amuleto" }, { value: "amulet", label: "Amuleto" },
@@ -70,13 +76,22 @@ export function MarketplaceFilters({
{ value: "other", label: "Outros" }, { value: "other", label: "Outros" },
]; ];
const vocationOptions = [
{ value: "Knight", label: "Knight" },
{ value: "Paladin", label: "Paladin" },
{ value: "Sorcerer", label: "Sorcerer" },
{ value: "Druid", label: "Druid" },
{ value: "Sorcerers and Druids", label: "Sorcerers and Druids" },
{ value: "Todas", label: "Todas" },
];
const sortOptions = [ const sortOptions = [
{ value: "name", label: "Nome", icon: SortAsc }, { value: "name", label: "Nome (A→Z)", icon: SortAsc },
{ value: "price-asc", label: "Preço (Menor)", icon: SortAsc }, { value: "level-asc", label: "Nível (Menor)", icon: SortAsc },
{ value: "price-desc", label: "Preço (Maior)", icon: SortDesc }, { value: "level-desc", label: "Nível (Maior)", icon: SortDesc },
{ value: "rarity", label: "Raridade", icon: Star }, { value: "weight-asc", label: "Peso (Leves)", icon: SortAsc },
{ value: "trending", label: "Tendência", icon: TrendingUp }, { value: "weight-desc", label: "Peso (Pesados)", icon: SortDesc },
{ value: "recent", label: "Recente", icon: Clock }, { value: "category", label: "Categoria (A→Z)", icon: SortAsc },
]; ];
const handleSearch = (query: string) => { const handleSearch = (query: string) => {
@@ -114,18 +129,20 @@ export function MarketplaceFilters({
rarity: [], rarity: [],
category: [], category: [],
priceRange: { min: 0, max: 100000 }, priceRange: { min: 0, max: 100000 },
vocation: null,
}); });
setSearchQuery(""); setSearchQuery("");
onFilter({ onFilter({
rarity: [], rarity: [],
category: [], category: [],
priceRange: { min: 0, max: 100000 }, priceRange: { min: 0, max: 100000 },
vocation: null,
}); });
onSearch(""); onSearch("");
}; };
const activeFiltersCount = const activeFiltersCount =
activeFilters.rarity.length + activeFilters.category.length; activeFilters.rarity.length + activeFilters.category.length + (activeFilters.vocation ? 1 : 0);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -142,7 +159,8 @@ export function MarketplaceFilters({
{/* Filtros e ordenação */} {/* Filtros e ordenação */}
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
{/* Filtro por raridade */} {/* Filtro por raridade (opcional) */}
{showRarity && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="outline" className="gap-2">
@@ -173,6 +191,7 @@ export function MarketplaceFilters({
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
{/* Filtro por categoria */} {/* Filtro por categoria */}
<DropdownMenu> <DropdownMenu>
@@ -205,6 +224,45 @@ export function MarketplaceFilters({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Filtro por Vocação */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<Filter className="h-4 w-4" />
Vocação
{activeFilters.vocation && (
<Badge variant="secondary" className="ml-1">
1
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Filtrar por Vocação</DropdownMenuLabel>
<DropdownMenuSeparator />
{vocationOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => {
const newFilters = {
...activeFilters,
vocation:
activeFilters.vocation === option.value ? null : option.value,
};
setActiveFilters(newFilters);
onFilter(newFilters);
}}
className="flex items-center gap-2"
>
<span>{option.label}</span>
{activeFilters.vocation === option.value && (
<X className="h-3 w-3 ml-auto" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Ordenação */} {/* Ordenação */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -278,6 +336,21 @@ export function MarketplaceFilters({
</Badge> </Badge>
); );
})} })}
{activeFilters.vocation && (
<Badge
key={activeFilters.vocation}
variant="secondary"
className="gap-1 cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
onClick={() => {
const newFilters = { ...activeFilters, vocation: null };
setActiveFilters(newFilters);
onFilter(newFilters);
}}
>
{activeFilters.vocation}
<X className="h-3 w-3" />
</Badge>
)}
</div> </div>
)} )}
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Loaded 100 of 2597 files, more files were not shown because too many files have changed in this diff. Show more