feat(item-card): add 'market'/'catalog' mode prop with defensive price checks

Introduces optional `mode` prop ("market"/"catalog") to conditionally render components like rarity badges. Adds defensive checks for missing price data and priceHistory properties to prevent runtime errors. New icons `Badge` and `Boxes` imported from lucide-react.
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-30 14:10:46 -03:00
1 parent 29ba5aece0
commit 44d7f8ce81
21 files changed
+1181 -714

No files matched your search

+174 -133
View File
@@ -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) {
<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">
{/* 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 */}
<Badge
className={`${
rarityColors[item.rarity]
} text-white absolute top-3 left-3 z-20 shadow-lg ${getRarityGlow(
item.rarity
)} ${item.rarity === "legendary" ? "animate-pulse" : ""} ${
item.rarity === "epic" ? "animate-pulse" : ""
}`}
>
{item.rarity}
</Badge>
{/* Badge de raridade com glow (apenas em modo market) */}
{mode === "market" && (
<Badge
className={`${
rarityColors[item.rarity]
} text-white absolute top-3 left-3 z-20 shadow-lg ${getRarityGlow(
item.rarity
)} ${item.rarity === "legendary" ? "animate-pulse" : ""} ${
item.rarity === "epic" ? "animate-pulse" : ""
}`}
>
{item.rarity}
</Badge>
)}
{/* Botão de favorito */}
<Button
@@ -130,22 +145,34 @@ export function ItemCard({ item }: ItemCardProps) {
/>
</Button>
{/* Hot Deal Badge */}
{isHotDeal && (
{/* 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>
)}
{/* Í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="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
{typeof CategoryIcon === "string" ? (
CategoryIcon
) : (
<CategoryIcon className="w-10 h-10" />
)}
</div>
{item.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.image}
alt={item.name}
className="w-20 h-20 object-contain drop-shadow-sm"
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" ? (
CategoryIcon
) : (
<CategoryIcon className="w-10 h-10" />
)}
</div>
)}
</div>
</div>
@@ -220,10 +247,17 @@ export function ItemCard({ item }: ItemCardProps) {
{/* Requisitos */}
{item.requirements && (
<div className="space-y-1">
<div className="flex flex-wrap gap-2">
{item.requirements.level && (
<div className="text-xs text-muted-foreground">
📊 Level {item.requirements.level}+
<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>
</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>
)}
{item.requirements.vocation && (
@@ -240,113 +274,120 @@ export function ItemCard({ item }: ItemCardProps) {
<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>
)}
{/* Divisor visual */}
<div className="border-t border-border/50" />
{mode === "market" && item.price && (
<>
{/* Divisor visual */}
<div className="border-t border-border/50" />
{/* 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>
{/* 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>
<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"
}`}
{/* 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 ? "+" : ""}
{priceDifference.toFixed(1)}% diferença
</span>
{t("viewDetails")}
</Button>
<Button className="w-full bg-green-600 hover:bg-green-700 text-white" size="sm">
{t("buyNow")}
</Button>
</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>
</MagicCard>
</div>
+8 -2
View File
@@ -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 */}
<div className="flex flex-wrap items-center gap-4">
{/* Filtro por raridade */}
{/* Filtro por raridade (opcional) */}
{showRarity && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
@@ -173,6 +178,7 @@ export function MarketplaceFilters({
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Filtro por categoria */}
<DropdownMenu>