feat(item-card): add 'market'/'catalog' mode prop with defensive price checks
Introduces optional `mode` prop ("market"/"catalog") to conditionally render components like rarity badges. Adds defensive checks for missing price data and priceHistory properties to prevent runtime errors. New icons `Badge` and `Boxes` imported from lucide-react.
This commit is contained in:
1 parent
29ba5aece0
commit
44d7f8ce81
21 files changed
+1181
-714
No files matched your search
+174
-133
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user