235 lines
7.0 KiB
TypeScript
235 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { Crown, Image as ImageIcon, Package, Sparkles } from "lucide-react";
|
|
import {
|
|
type CatalogItemData,
|
|
getPointsLabel,
|
|
} from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
|
import {
|
|
CurrencyIcon,
|
|
currencyKindFromPointsType,
|
|
} from "@/components/shared/currency-icon";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface ItemsShopPreviewProps {
|
|
items: CatalogItemData[];
|
|
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
|
layout: string;
|
|
canEdit: boolean;
|
|
onItemClick?: (item: CatalogItemData) => void;
|
|
}
|
|
|
|
export function ItemsShopPreview({
|
|
items,
|
|
furniRevisionMap,
|
|
layout,
|
|
canEdit,
|
|
onItemClick,
|
|
}: ItemsShopPreviewProps) {
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
|
|
<Package className="h-10 w-10 text-muted-foreground/40" />
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
No items in this page yet
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const gridClass = getGridClass(layout);
|
|
const tileSize = getTileSize(layout);
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
|
|
<div className="mb-3 flex items-center justify-between text-xs text-[var(--admin-text-muted)]">
|
|
<span className="font-medium">Shop preview · {layout}</span>
|
|
<span>{items.length} items</span>
|
|
</div>
|
|
<div className={cn("grid gap-2", gridClass)}>
|
|
{items.map((item) => {
|
|
const firstId = parseFirstItemId(item.itemIds);
|
|
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
|
|
const points = getPointsLabel(item.pointsType);
|
|
const isClub = item.clubOnly === "1";
|
|
const isOffer = item.haveOffer === "1" && item.offerId > 0;
|
|
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => onItemClick?.(item)}
|
|
disabled={!canEdit && !onItemClick}
|
|
className={cn(
|
|
"group relative flex flex-col overflow-hidden rounded-md border bg-card shadow-sm transition-all",
|
|
canEdit &&
|
|
"hover:border-primary hover:shadow-md hover:scale-[1.02] cursor-pointer",
|
|
!canEdit && "cursor-default",
|
|
)}
|
|
title={item.catalogName || item.baseName}
|
|
>
|
|
{/* Tile icon */}
|
|
<div
|
|
className="relative flex items-center justify-center bg-gradient-to-br from-muted/30 to-muted/60"
|
|
style={{ height: tileSize }}
|
|
>
|
|
<ShopFurniIcon
|
|
classname={furni?.classname ?? ""}
|
|
revision={furni?.revision ?? 0}
|
|
size={Math.floor(tileSize * 0.7)}
|
|
/>
|
|
|
|
{/* Badges overlay */}
|
|
<div className="absolute right-1 top-1 flex flex-col gap-0.5">
|
|
{isClub && (
|
|
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-warning-subtle)] px-1 text-[8px] text-[var(--admin-warning)]">
|
|
<Crown className="h-2.5 w-2.5" />
|
|
HC
|
|
</Badge>
|
|
)}
|
|
{isOffer && (
|
|
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-error)] px-1 text-[8px] text-[var(--admin-error-foreground)]">
|
|
<Sparkles className="h-2.5 w-2.5" />
|
|
OFFER
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Amount badge */}
|
|
{item.amount > 1 && (
|
|
<Badge className="absolute left-1 top-1 h-4 border-0 bg-[var(--admin-surface)] px-1 text-[9px] text-[var(--admin-text)]">
|
|
x{item.amount}
|
|
</Badge>
|
|
)}
|
|
|
|
{/* Limited */}
|
|
{item.limitedStack > 0 && (
|
|
<Badge className="absolute bottom-1 left-1 h-4 border-0 bg-[var(--admin-accent)] px-1 text-[8px] text-[var(--admin-accent-foreground)]">
|
|
LTD {item.limitedSells}/{item.limitedStack}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Name + price */}
|
|
<div className="border-t bg-background/95 px-2 py-1.5">
|
|
<p className="truncate text-xs font-medium leading-tight">
|
|
{item.catalogName || item.baseName || `#${item.id}`}
|
|
</p>
|
|
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono">
|
|
{item.costCredits > 0 && (
|
|
<span className="inline-flex items-center gap-0.5 text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
|
|
<CurrencyIcon kind="credits" size={12} />
|
|
{item.costCredits}
|
|
</span>
|
|
)}
|
|
{item.costPoints > 0 && (
|
|
<span
|
|
className={`inline-flex items-center gap-0.5 ${points.color}`}
|
|
>
|
|
<CurrencyIcon
|
|
kind={currencyKindFromPointsType(item.pointsType)}
|
|
size={12}
|
|
/>
|
|
{item.costPoints}
|
|
</span>
|
|
)}
|
|
{item.costCredits === 0 && item.costPoints === 0 && (
|
|
<span className="text-muted-foreground">Free</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
|
|
function parseFirstItemId(itemIds: string): number | null {
|
|
if (!itemIds) return null;
|
|
const first = itemIds.split(";")[0]?.trim();
|
|
const n = parseInt(first, 10);
|
|
return Number.isNaN(n) ? null : n;
|
|
}
|
|
|
|
function getGridClass(layout: string): string {
|
|
switch (layout) {
|
|
case "spaces_new":
|
|
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
|
|
case "frontpage":
|
|
return "grid-cols-1 sm:grid-cols-2 md:grid-cols-3";
|
|
case "single_bundle":
|
|
case "room_bundle":
|
|
return "grid-cols-1 sm:grid-cols-2";
|
|
case "pets":
|
|
case "pets2":
|
|
case "pets3":
|
|
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
|
|
default:
|
|
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5";
|
|
}
|
|
}
|
|
|
|
function getTileSize(layout: string): number {
|
|
switch (layout) {
|
|
case "frontpage":
|
|
case "single_bundle":
|
|
case "room_bundle":
|
|
return 140;
|
|
case "spaces_new":
|
|
return 110;
|
|
default:
|
|
return 96;
|
|
}
|
|
}
|
|
|
|
// ── Shop tile furni icon (resizable) ────────────────────────────
|
|
|
|
function ShopFurniIcon({
|
|
classname,
|
|
revision,
|
|
size,
|
|
}: {
|
|
classname: string;
|
|
revision: number;
|
|
size: number;
|
|
}) {
|
|
if (!classname) {
|
|
return (
|
|
<ImageIcon
|
|
className="text-muted-foreground/40"
|
|
style={{ width: size * 0.5, height: size * 0.5 }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const primary = getLocalIconUrl(classname);
|
|
|
|
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
|
|
const img = e.target as HTMLImageElement;
|
|
if (!img.dataset.fallback && revision > 0) {
|
|
img.dataset.fallback = "cdn";
|
|
img.src = getHabboCdnIconUrl(classname, revision);
|
|
} else {
|
|
img.style.opacity = "0";
|
|
}
|
|
}
|
|
|
|
return (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={primary}
|
|
alt={classname}
|
|
className="object-contain"
|
|
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
|
|
onError={handleError}
|
|
loading="lazy"
|
|
/>
|
|
);
|
|
}
|