feat(library): add vocation filter, normalize categories, improve grid layout
Added vocation state and included it in query string builder, updated onFilter to accept a vocation parameter, improved grid layout with grid flow classes, normalized category names for consistent item icons, and refined stats mapping to handle type safety.
This commit is contained in:
1 parent
44d7f8ce81
commit
2b790d8eb4
19 files changed
+2638
-173
No files matched your search
@@ -20,6 +20,7 @@ export default function Header() {
|
||||
const locale = useLocale();
|
||||
|
||||
const links = [
|
||||
{ href: `/${locale}/library`, label: "Library" },
|
||||
{ href: `/${locale}/market`, label: t("market") },
|
||||
{ href: `/${locale}/sobre`, label: t("about") },
|
||||
{ href: `/${locale}/precos`, label: t("prices") },
|
||||
|
||||
+349
-29
@@ -27,8 +27,13 @@ import {
|
||||
Cross,
|
||||
Badge as BadgeIcon,
|
||||
Boxes,
|
||||
Gem,
|
||||
FlaskConical,
|
||||
Wrench,
|
||||
ScrollText,
|
||||
Leaf,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getRarityColor,
|
||||
getRarityBgColor,
|
||||
@@ -44,6 +49,170 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
const t = useTranslations("home.item");
|
||||
const [isFavorited, setIsFavorited] = 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 = {
|
||||
common: "bg-gray-500",
|
||||
@@ -60,14 +229,14 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
legs: Shield,
|
||||
boots: Shield,
|
||||
shield: Shield,
|
||||
amulet: "💎",
|
||||
ring: "💍",
|
||||
potion: "🧪",
|
||||
rune: "📜",
|
||||
tool: "🔧",
|
||||
ammunition: "🏹",
|
||||
other: "📦",
|
||||
};
|
||||
amulet: Gem,
|
||||
ring: Gem,
|
||||
potion: FlaskConical,
|
||||
rune: ScrollText,
|
||||
tool: Wrench,
|
||||
ammunition: Boxes,
|
||||
other: Boxes,
|
||||
} as const;
|
||||
|
||||
const CategoryIcon = categoryIcons[item.category];
|
||||
|
||||
@@ -107,11 +276,11 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative h-full"
|
||||
className="group relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
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 */}
|
||||
<div className="relative aspect-square bg-linear-to-br from-background to-muted p-6">
|
||||
{/* Badge de raridade com glow (apenas em modo market) */}
|
||||
@@ -147,8 +316,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
|
||||
{/* Hot Deal Badge (apenas em modo market) */}
|
||||
{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">
|
||||
🔥 Hot Deal
|
||||
<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">
|
||||
<Flame className="h-3 w-3" />
|
||||
Hot Deal
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -167,7 +337,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
|
||||
{typeof CategoryIcon === "string" ? (
|
||||
CategoryIcon
|
||||
null
|
||||
) : (
|
||||
<CategoryIcon className="w-10 h-10" />
|
||||
)}
|
||||
@@ -189,6 +359,26 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Atualizado há 5 min</span>
|
||||
</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>
|
||||
|
||||
{/* Estatísticas agrupadas */}
|
||||
@@ -199,7 +389,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Sword className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">
|
||||
⚔️ {item.stats.attack} ATK
|
||||
{item.stats.attack} ATK
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -207,7 +397,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">
|
||||
🛡️ {item.stats.defense} DEF
|
||||
{item.stats.defense} DEF
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -215,43 +405,55 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">
|
||||
🛡️ {item.stats.armor} ARM
|
||||
{item.stats.armor} ARM
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* Proteções elementais */}
|
||||
{item.protections && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ScrollRow ariaLabel="protections">
|
||||
{item.protections.fire && (
|
||||
<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" />
|
||||
<span>🔥 Fire +{item.protections.fire}%</span>
|
||||
<span>Fire +{item.protections.fire}%</span>
|
||||
</div>
|
||||
)}
|
||||
{item.protections.ice && (
|
||||
<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" />
|
||||
<span>❄️ Ice +{item.protections.ice}%</span>
|
||||
<span>Ice +{item.protections.ice}%</span>
|
||||
</div>
|
||||
)}
|
||||
{item.protections.energy && (
|
||||
<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" />
|
||||
<span>⚡ Energy +{item.protections.energy}%</span>
|
||||
<span>Energy +{item.protections.energy}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollRow>
|
||||
)}
|
||||
|
||||
{/* Requisitos */}
|
||||
{item.requirements && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.requirements.level && (
|
||||
{typeof (item as any).requirements?.level === "number" && (
|
||||
<div className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-1 rounded">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>Lvl {item.requirements.level}+</span>
|
||||
<span>Lvl {(item as any).requirements.level}+</span>
|
||||
</div>
|
||||
)}
|
||||
{(item as any).requirements?.skill && (
|
||||
@@ -262,16 +464,95 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
)}
|
||||
{item.requirements.vocation && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
👤 Best for {item.requirements.vocation}
|
||||
Best for {formatVocation(item.requirements.vocation)}
|
||||
</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 && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Weight className="h-4 w-4" />
|
||||
<span>⚖️ {item.stats.weight} oz</span>
|
||||
<span>{item.stats.weight} oz</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -284,6 +565,45 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(item as any).dropa_de && (
|
||||
(() => {
|
||||
const raw = String((item as any).dropa_de || "");
|
||||
const normalized = raw.trim();
|
||||
// Casos especiais de "nenhum/ninguém"
|
||||
const none = /^(nenhum|nenhuma|ningu[eé]m)\.?$/i.test(normalized);
|
||||
if (none) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<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>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{mode === "market" && item.price && (
|
||||
<>
|
||||
{/* Divisor visual */}
|
||||
@@ -354,21 +674,21 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
<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 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 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 className="font-bold">{item.priceHistory.average.toLocaleString()} gp</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ interface FilterState {
|
||||
rarity: string[];
|
||||
category: string[];
|
||||
priceRange: { min: number; max: number };
|
||||
vocation?: string | null;
|
||||
}
|
||||
|
||||
export function MarketplaceFilters({
|
||||
@@ -53,6 +54,7 @@ export function MarketplaceFilters({
|
||||
rarity: [],
|
||||
category: [],
|
||||
priceRange: { min: 0, max: 100000 },
|
||||
vocation: null,
|
||||
});
|
||||
const [sortBy, setSortBy] = useState("name");
|
||||
|
||||
@@ -74,13 +76,22 @@ export function MarketplaceFilters({
|
||||
{ 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 = [
|
||||
{ value: "name", label: "Nome", icon: SortAsc },
|
||||
{ value: "price-asc", label: "Preço (Menor)", icon: SortAsc },
|
||||
{ value: "price-desc", label: "Preço (Maior)", icon: SortDesc },
|
||||
{ value: "rarity", label: "Raridade", icon: Star },
|
||||
{ value: "trending", label: "Tendência", icon: TrendingUp },
|
||||
{ value: "recent", label: "Recente", icon: Clock },
|
||||
{ value: "name", label: "Nome (A→Z)", icon: SortAsc },
|
||||
{ value: "level-asc", label: "Nível (Menor)", icon: SortAsc },
|
||||
{ value: "level-desc", label: "Nível (Maior)", icon: SortDesc },
|
||||
{ value: "weight-asc", label: "Peso (Leves)", icon: SortAsc },
|
||||
{ value: "weight-desc", label: "Peso (Pesados)", icon: SortDesc },
|
||||
{ value: "category", label: "Categoria (A→Z)", icon: SortAsc },
|
||||
];
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
@@ -118,18 +129,20 @@ export function MarketplaceFilters({
|
||||
rarity: [],
|
||||
category: [],
|
||||
priceRange: { min: 0, max: 100000 },
|
||||
vocation: null,
|
||||
});
|
||||
setSearchQuery("");
|
||||
onFilter({
|
||||
rarity: [],
|
||||
category: [],
|
||||
priceRange: { min: 0, max: 100000 },
|
||||
vocation: null,
|
||||
});
|
||||
onSearch("");
|
||||
};
|
||||
|
||||
const activeFiltersCount =
|
||||
activeFilters.rarity.length + activeFilters.category.length;
|
||||
activeFilters.rarity.length + activeFilters.category.length + (activeFilters.vocation ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -211,6 +224,45 @@ export function MarketplaceFilters({
|
||||
</DropdownMenuContent>
|
||||
</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 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -284,6 +336,21 @@ export function MarketplaceFilters({
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user