feat(catalog): add auto-category wizard with live preview and smart suggestions
- Add AutoCategoryDialog: pick furni (or empty page), suggest caption/icon/layout, live preview - Add createAutoCategory server action (page + offers in one export) with CatalogKind - Extend furni search API with interactionType - Share ShopTile and refactor inline-editor/items-shop-preview to use it - Add suggestion heuristics (suggestCategoryName/dIcon/layout) with tests - Add autoCategory translations (en/nl)
This commit is contained in:
1 parent
baeb54aeb5
commit
f832479e52
14 files changed
+2577
-567
No files matched your search
@@ -0,0 +1,727 @@
|
||||
"use client";
|
||||
|
||||
import { Folder, Home, Loader2, Plus, Search, Wand2, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type CatalogKind, createAutoCategory } from "@/actions/catalog";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||
import { useCatalogManager } from "@/components/admin/catalog-manager/use-catalog-store";
|
||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
suggestCatalogIcon,
|
||||
suggestCatalogLayoutFromBaseItems,
|
||||
suggestCategoryName,
|
||||
} from "@/features/catalog/domain/organize";
|
||||
import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts";
|
||||
import { getLocalIconUrl } from "@/lib/furni/classname";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LiveCatalogPreview } from "./live-catalog-preview";
|
||||
|
||||
interface FurniResult {
|
||||
id: number;
|
||||
itemName: string;
|
||||
publicName: string;
|
||||
type: string;
|
||||
spriteId: number;
|
||||
interactionType: string;
|
||||
}
|
||||
|
||||
interface AutoCategoryDialogProps {
|
||||
catalogType: CatalogKind;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: (pageId: number) => void;
|
||||
}
|
||||
|
||||
export function AutoCategoryDialog({
|
||||
catalogType,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: AutoCategoryDialogProps) {
|
||||
const { state } = useCatalogManager();
|
||||
const t = useTranslations("pages.admin.catalog.workspace.autoCategory");
|
||||
const { rootPages, selectedPageId } = state;
|
||||
|
||||
const [mode, setMode] = useState<"empty" | "furni">("furni");
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
const [results, setResults] = useState<FurniResult[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<FurniResult[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [parentId, setParentId] = useState<number>(-1);
|
||||
const [caption, setCaption] = useState("");
|
||||
const [iconImage, setIconImage] = useState(0);
|
||||
const [layout, setLayout] = useState<CatalogLayout>("default_3x3");
|
||||
const [enabled, setEnabled] = useState("1");
|
||||
const [visible, setVisible] = useState("1");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const [captionTouched, setCaptionTouched] = useState(false);
|
||||
const [iconTouched, setIconTouched] = useState(false);
|
||||
const [layoutTouched, setLayoutTouched] = useState(false);
|
||||
|
||||
const [freeMode, setFreeMode] = useState(true);
|
||||
const [credits, setCredits] = useState(0);
|
||||
const [points, setPoints] = useState(0);
|
||||
const [pointsType, setPointsType] = useState(0);
|
||||
|
||||
// Default the parent to the currently selected page when the wizard opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setParentId(selectedPageId ?? -1);
|
||||
}, [open, selectedPageId]);
|
||||
|
||||
const parentOptions = useMemo(() => {
|
||||
const options: Array<{ id: number; caption: string }> = [
|
||||
{ id: -1, caption: "Root" },
|
||||
];
|
||||
const selectedNode =
|
||||
selectedPageId != null ? state.nodes.get(selectedPageId) : undefined;
|
||||
if (selectedNode)
|
||||
options.push({ id: selectedNode.id, caption: selectedNode.caption });
|
||||
for (const p of rootPages) {
|
||||
if (!options.some((o) => o.id === p.id)) {
|
||||
options.push({ id: p.id, caption: p.caption });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [rootPages, selectedPageId, state.nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(query.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mode !== "furni" || debounced.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setSearchLoading(true);
|
||||
const ac = new AbortController();
|
||||
fetch(
|
||||
`/api/admin/furni/search?q=${encodeURIComponent(debounced)}&limit=40`,
|
||||
{
|
||||
signal: ac.signal,
|
||||
},
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((data) => setResults(data.results ?? []))
|
||||
.catch((error) =>
|
||||
console.error("[AutoCategory] furni search failed:", error),
|
||||
)
|
||||
.finally(() => setSearchLoading(false));
|
||||
return () => ac.abort();
|
||||
}, [debounced, mode, open]);
|
||||
|
||||
const sug = useMemo(() => {
|
||||
if (mode !== "furni" || selected.length === 0) return null;
|
||||
return {
|
||||
name: suggestCategoryName(selected),
|
||||
layout: suggestCatalogLayoutFromBaseItems(
|
||||
selected.map((f) => ({
|
||||
type: f.type,
|
||||
interactionType: f.interactionType,
|
||||
})),
|
||||
),
|
||||
icon: suggestCatalogIcon(selected),
|
||||
};
|
||||
}, [selected, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sug) return;
|
||||
if (!captionTouched && sug.name) setCaption(sug.name);
|
||||
if (!layoutTouched && sug.layout) setLayout(sug.layout.layout);
|
||||
if (!iconTouched && sug.icon > 0) setIconImage(sug.icon);
|
||||
}, [sug, captionTouched, layoutTouched, iconTouched]);
|
||||
|
||||
const previewItems = useMemo<CatalogItemData[]>(() => {
|
||||
if (mode !== "furni" || selected.length === 0) return [];
|
||||
return selected.map((f, idx) => ({
|
||||
id: -1 - idx,
|
||||
catalogName: f.publicName || f.itemName,
|
||||
itemIds: String(f.id),
|
||||
costCredits: freeMode ? 0 : credits,
|
||||
costPoints: freeMode ? 0 : points,
|
||||
pointsType: freeMode ? 0 : pointsType,
|
||||
amount: 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: idx,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
extradata: "",
|
||||
baseName: f.publicName || f.itemName,
|
||||
baseItemName: f.itemName,
|
||||
spriteId: f.spriteId,
|
||||
baseItem: {
|
||||
id: f.id,
|
||||
spriteId: f.spriteId,
|
||||
publicName: f.publicName || f.itemName,
|
||||
itemName: f.itemName,
|
||||
type: f.type,
|
||||
width: 1,
|
||||
length: 1,
|
||||
stackHeight: 0,
|
||||
allowStack: 1,
|
||||
allowSit: 0,
|
||||
allowLay: 0,
|
||||
allowWalk: 0,
|
||||
allowGift: 1,
|
||||
allowTrade: 1,
|
||||
allowRecycle: 0,
|
||||
allowMarketplaceSell: 0,
|
||||
allowInventoryStack: 1,
|
||||
interactionType: f.interactionType,
|
||||
interactionModesCount: 1,
|
||||
vendingIds: "0",
|
||||
customparams: "",
|
||||
effectIdMale: 0,
|
||||
effectIdFemale: 0,
|
||||
clothingOnWalk: "",
|
||||
},
|
||||
}));
|
||||
}, [selected, mode, freeMode, credits, points, pointsType]);
|
||||
|
||||
const toggleFurni = useCallback((f: FurniResult) => {
|
||||
setSelected((prev) => {
|
||||
if (prev.some((x) => x.id === f.id)) {
|
||||
return prev.filter((x) => x.id !== f.id);
|
||||
}
|
||||
return [...prev, f];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (isCreating) return;
|
||||
if (!caption.trim()) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
if (mode === "furni" && selected.length === 0) {
|
||||
toast.error("Select at least one furni item");
|
||||
return;
|
||||
}
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const offerRows =
|
||||
mode === "furni"
|
||||
? selected.map((f) => ({
|
||||
baseId: f.id,
|
||||
credits: freeMode ? 0 : credits,
|
||||
points: freeMode ? 0 : points,
|
||||
pointsType: freeMode ? 0 : pointsType,
|
||||
}))
|
||||
: [];
|
||||
const result = await createAutoCategory({
|
||||
kind: catalogType,
|
||||
parentId,
|
||||
caption: caption.trim(),
|
||||
pageLayout: layout,
|
||||
iconImage: iconImage,
|
||||
enabled: enabled as "0" | "1",
|
||||
visible: visible as "0" | "1",
|
||||
offerRows,
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error ?? "Failed");
|
||||
const pageId = result.data?.pageId;
|
||||
if (pageId == null) throw new Error("No page id returned");
|
||||
toast.success(
|
||||
`Created "${caption.trim()}" with ${offerRows.length} items`,
|
||||
);
|
||||
onCreated(pageId);
|
||||
resetState();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("[AutoCategory] create failed:", error);
|
||||
toast.error("Failed to create category");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
setMode("furni");
|
||||
setQuery("");
|
||||
setDebounced("");
|
||||
setSelected([]);
|
||||
setCaption("");
|
||||
setIconImage(0);
|
||||
setLayout("default_3x3");
|
||||
setEnabled("1");
|
||||
setVisible("1");
|
||||
setCaptionTouched(false);
|
||||
setIconTouched(false);
|
||||
setLayoutTouched(false);
|
||||
setFreeMode(true);
|
||||
setCredits(0);
|
||||
setPoints(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[90vh] max-h-[90vh] w-[95vw] max-w-[95vw] flex-col overflow-hidden p-0">
|
||||
<DialogHeader className="flex-none border-b px-4 py-3">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
{t("title")}
|
||||
{selected.length > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("itemsBadge", { count: selected.length })}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{/* Left: form */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||
{/* Mode */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === "furni" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setMode("furni")}
|
||||
>
|
||||
{t("fromFurni")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === "empty" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
setMode("empty");
|
||||
setSelected([]);
|
||||
}}
|
||||
>
|
||||
{t("emptyPage")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Furni search + picker */}
|
||||
{mode === "furni" && (
|
||||
<div className="space-y-3 rounded-md border bg-[var(--admin-surface)] p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
{searchLoading && (
|
||||
<Loader2 className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{debounced.length >= 2 && (
|
||||
<div className="max-h-[140px] divide-y overflow-y-auto rounded border">
|
||||
{results.length === 0 && !searchLoading ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
{t("noResults")}
|
||||
</p>
|
||||
) : (
|
||||
results.map((r) => {
|
||||
const isSelected = selected.some((x) => x.id === r.id);
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => toggleFurni(r)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-accent",
|
||||
isSelected && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<FurniThumb item={r} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{r.publicName || (
|
||||
<span className="italic text-muted-foreground">
|
||||
{r.itemName}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
#{r.id} · {r.type} · {r.interactionType}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[9px]"
|
||||
>
|
||||
{t("added")}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="flex max-h-[80px] flex-wrap gap-1 overflow-y-auto">
|
||||
{selected.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => toggleFurni(f)}
|
||||
className="group flex items-center gap-1 rounded bg-[var(--admin-accent-subtle)] px-1.5 py-0.5 text-[10px]"
|
||||
>
|
||||
<FurniThumb item={f} size={14} />
|
||||
{f.publicName || f.itemName}
|
||||
<X className="h-2.5 w-2.5 text-muted-foreground group-hover:text-destructive" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{mode === "furni" && (
|
||||
<div className="space-y-2 rounded-md border bg-[var(--admin-surface)] p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("price")}
|
||||
<Button
|
||||
type="button"
|
||||
variant={freeMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={() => setFreeMode(true)}
|
||||
>
|
||||
{t("free")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={!freeMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={() => setFreeMode(false)}
|
||||
>
|
||||
{t("setPrice")}
|
||||
</Button>
|
||||
</div>
|
||||
{!freeMode && (
|
||||
<div className="flex gap-2 text-xs">
|
||||
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{t("credits")}
|
||||
<input
|
||||
type="number"
|
||||
className="w-16 rounded border bg-[var(--admin-canvas)] px-1.5 py-1 text-xs"
|
||||
value={credits}
|
||||
onChange={(e) => setCredits(Number(e.target.value))}
|
||||
min={0}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{t("points")}
|
||||
<input
|
||||
type="number"
|
||||
className="w-16 rounded border bg-[var(--admin-canvas)] px-1.5 py-1 text-xs"
|
||||
value={points}
|
||||
onChange={(e) => setPoints(Number(e.target.value))}
|
||||
min={0}
|
||||
/>
|
||||
</label>
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{t("pointsType")}
|
||||
<Select
|
||||
value={String(pointsType)}
|
||||
onValueChange={(v) => setPointsType(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-24 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0" className="text-xs">
|
||||
Duckets
|
||||
</SelectItem>
|
||||
<SelectItem value="5" className="text-xs">
|
||||
Diamonds
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parent + settings */}
|
||||
<div className="space-y-3 rounded-md border bg-[var(--admin-surface)] p-3">
|
||||
<div className="flex flex-col gap-1 text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("parent")}
|
||||
<Select
|
||||
value={String(parentId)}
|
||||
onValueChange={(v) => setParentId(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentOptions.map((p) => (
|
||||
<SelectItem
|
||||
key={p.id}
|
||||
value={String(p.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{p.id === -1 ? (
|
||||
<Home className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<Folder className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{p.id === -1 ? t("root") : p.caption}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("caption")}
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={caption}
|
||||
onChange={(e) => {
|
||||
setCaption(e.target.value);
|
||||
setCaptionTouched(true);
|
||||
}}
|
||||
placeholder={t("captionPlaceholder")}
|
||||
/>
|
||||
{sug?.name && caption !== sug.name && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2 text-[10px]"
|
||||
onClick={() => {
|
||||
setCaption(sug.name ?? "");
|
||||
setCaptionTouched(false);
|
||||
}}
|
||||
>
|
||||
{sug.name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("icon")}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<IconPicker
|
||||
value={iconImage}
|
||||
onChange={(id) => {
|
||||
setIconImage(id);
|
||||
setIconTouched(true);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2"
|
||||
>
|
||||
<CatalogIcon iconImage={iconImage} size={18} />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{iconImage > 0 ? `#${iconImage}` : t("pickIcon")}
|
||||
</span>
|
||||
</Button>
|
||||
</IconPicker>
|
||||
{sug && sug.icon > 0 && iconImage !== sug.icon && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-[10px]"
|
||||
onClick={() => {
|
||||
setIconImage(sug.icon);
|
||||
setIconTouched(false);
|
||||
}}
|
||||
>
|
||||
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
|
||||
{sug.icon}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("layout")}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{CATALOG_LAYOUTS.map((l) => {
|
||||
const color =
|
||||
LAYOUT_COLORS[l] || "bg-muted text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLayout(l);
|
||||
setLayoutTouched(true);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 font-mono text-[10px] transition-colors",
|
||||
layout === l
|
||||
? cn(color, "border-current ring-1 ring-current/20")
|
||||
: "border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text-muted)] hover:border-current",
|
||||
)}
|
||||
>
|
||||
{l.replace("default_3x3", "3x3")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sug?.layout && layout !== sug.layout.layout && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-fit border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-2 text-[10px] text-[var(--admin-warning)]"
|
||||
onClick={() => {
|
||||
setLayout(sug.layout ? sug.layout.layout : layout);
|
||||
setLayoutTouched(false);
|
||||
}}
|
||||
>
|
||||
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
|
||||
{t("suggestedLayout", { layout: sug.layout.layout })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled === "1"}
|
||||
onChange={(e) => setEnabled(e.target.checked ? "1" : "0")}
|
||||
/>
|
||||
{t("enabled")}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible === "1"}
|
||||
onChange={(e) => setVisible(e.target.checked ? "1" : "0")}
|
||||
/>
|
||||
{t("visible")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: live preview */}
|
||||
<div className="flex w-[340px] shrink-0 flex-col overflow-y-auto border-l">
|
||||
<LiveCatalogPreview
|
||||
form={{
|
||||
caption,
|
||||
pageLayout: layout,
|
||||
iconImage,
|
||||
enabled,
|
||||
visible,
|
||||
}}
|
||||
items={previewItems}
|
||||
loadingItems={false}
|
||||
onApplyLayout={(l) => setLayout(l as CatalogLayout)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none justify-end gap-2 border-t px-4 py-2.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={isCreating || !caption.trim()}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
{selected.length > 0
|
||||
? t("createWithItems", { count: selected.length })
|
||||
: t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FurniThumb({
|
||||
item,
|
||||
size = 24,
|
||||
}: {
|
||||
item: { itemName: string };
|
||||
size?: number;
|
||||
}) {
|
||||
const primary = getLocalIconUrl(item.itemName);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded bg-muted/50"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={primary}
|
||||
alt={item.itemName}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => {
|
||||
const img = e.target as HTMLImageElement;
|
||||
img.style.opacity = "0";
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUp, Loader2, Wand2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { reorderCatalogItems } from "@/actions/catalog-items";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
buildSortOrders,
|
||||
SORT_STRATEGIES,
|
||||
type SortStrategy,
|
||||
sortCatalogItems,
|
||||
suggestCatalogLayout,
|
||||
} from "@/features/catalog/domain/organize";
|
||||
|
||||
interface AutoOrganizeProps {
|
||||
items: CatalogItemData[];
|
||||
currentLayout: string;
|
||||
canEdit?: boolean;
|
||||
onSorted: () => void;
|
||||
onApplyLayout: (layout: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click organizational helpers: pick a smart sort order and apply it,
|
||||
* or apply the suggested layout for this page's offers.
|
||||
*/
|
||||
export function AutoOrganize({
|
||||
items,
|
||||
currentLayout,
|
||||
canEdit,
|
||||
onSorted,
|
||||
onApplyLayout,
|
||||
}: AutoOrganizeProps) {
|
||||
const [strategy, setStrategy] = useState<SortStrategy>("name-asc");
|
||||
const [sorting, setSorting] = useState(false);
|
||||
|
||||
const suggestion = items.length > 0 ? suggestCatalogLayout(items) : null;
|
||||
const showSuggestion =
|
||||
suggestion !== null && suggestion.layout !== currentLayout;
|
||||
|
||||
const disabled = !canEdit || items.length === 0;
|
||||
|
||||
async function handleSmartSort() {
|
||||
if (!canEdit || items.length === 0 || sorting) return;
|
||||
setSorting(true);
|
||||
try {
|
||||
const ordered = sortCatalogItems(items, strategy);
|
||||
const orders = buildSortOrders(ordered);
|
||||
const result = await reorderCatalogItems({ orders });
|
||||
if (!result.ok) throw new Error("Failed to sort");
|
||||
toast.success(
|
||||
`Sorted ${items.length} offers (${SORT_STRATEGIES.find((s) => s.id === strategy)?.label})`,
|
||||
);
|
||||
onSorted();
|
||||
} catch {
|
||||
toast.error("Failed to sort offers");
|
||||
} finally {
|
||||
setSorting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 rounded-md border bg-[var(--admin-surface)] px-2 py-1.5">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-[var(--admin-text-muted)]">
|
||||
<Wand2 className="h-3 w-3" />
|
||||
Auto
|
||||
</span>
|
||||
<Select
|
||||
value={strategy}
|
||||
onValueChange={(v) => setStrategy(v as SortStrategy)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-36 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_STRATEGIES.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id} className="text-xs">
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
disabled={disabled || sorting}
|
||||
onClick={() => void handleSmartSort()}
|
||||
>
|
||||
{sorting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownUp className="h-3 w-3" />
|
||||
)}
|
||||
Sort
|
||||
</Button>
|
||||
{showSuggestion && suggestion && canEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] text-[11px] text-[var(--admin-warning)] hover:bg-[var(--admin-warning-subtle)]"
|
||||
onClick={() => onApplyLayout(suggestion.layout)}
|
||||
>
|
||||
<Wand2 className="h-3 w-3" />
|
||||
Use {suggestion.layout} layout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid, Search, X } from "lucide-react";
|
||||
import { LayoutGrid, Search, Wand2, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -15,6 +15,7 @@ import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
|
||||
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
|
||||
import type { RootTab } from "@/types/catalog";
|
||||
import { AutoCategoryDialog } from "./auto-category-dialog";
|
||||
import { BreadcrumbBar } from "./breadcrumb-bar";
|
||||
import { InlineEditor, type PageUpdateData } from "./inline-editor";
|
||||
import { RootTabs } from "./root-tabs";
|
||||
@@ -165,6 +166,7 @@ function CatalogManagerInner({
|
||||
const rootRequests = useLatestRequest();
|
||||
const searchNavigation = useLatestRequest();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [autoOpen, setAutoOpen] = useState(false);
|
||||
const [searchTarget, setSearchTarget] = useState<{
|
||||
offerId: number | null;
|
||||
key: number;
|
||||
@@ -399,6 +401,23 @@ function CatalogManagerInner({
|
||||
{heading}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
{canEdit && catalogType === "normal" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={t("autoCategory.title")}
|
||||
title={t("autoCategory.title")}
|
||||
onClick={() => {
|
||||
if (!mayLeave()) return;
|
||||
setAutoOpen(true);
|
||||
}}
|
||||
>
|
||||
<Wand2 className="size-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{t("autoCategory.title")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -437,6 +456,16 @@ function CatalogManagerInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutoCategoryDialog
|
||||
catalogType={catalogType}
|
||||
open={autoOpen}
|
||||
onOpenChange={setAutoOpen}
|
||||
onCreated={(pageId) => {
|
||||
void loadRootPages();
|
||||
handleSelectPage(pageId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BreadcrumbBar onSelectPage={handleSelectPage} />
|
||||
|
||||
<RootTabs
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ExternalLink,
|
||||
Languages,
|
||||
Loader2,
|
||||
MonitorPlay,
|
||||
Package,
|
||||
Save,
|
||||
Settings,
|
||||
@@ -24,8 +25,9 @@ import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-
|
||||
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
||||
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
|
||||
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
|
||||
import { AutoOrganize } from "@/components/admin/catalog-manager/auto-organize";
|
||||
import { LiveCatalogPreview } from "@/components/admin/catalog-manager/live-catalog-preview";
|
||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -144,7 +146,7 @@ function InlineEditorSession({
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
initialOfferId ? "items" : "settings",
|
||||
);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(catalogType === "normal");
|
||||
|
||||
// Items/translate data (lazy loaded)
|
||||
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
|
||||
@@ -266,11 +268,13 @@ function InlineEditorSession({
|
||||
}
|
||||
}, [pageId, loadPage, initialOfferId]);
|
||||
|
||||
// Lazy-load items (shop catalog only — BC items live on the detail page)
|
||||
// Lazy-load items (shop catalog only — BC items live on the detail page).
|
||||
// Also eager-loads while the live preview panel is open so the preview
|
||||
// always reflects the page's offers.
|
||||
useEffect(() => {
|
||||
if (catalogType === "bc") return;
|
||||
if (
|
||||
(activeTab === "items" || activeTab === "translate") &&
|
||||
(activeTab === "items" || activeTab === "translate" || showPreview) &&
|
||||
!itemsLoaded &&
|
||||
!itemsLoading &&
|
||||
pageId &&
|
||||
@@ -285,6 +289,7 @@ function InlineEditorSession({
|
||||
pageId,
|
||||
loadItemsData,
|
||||
catalogType,
|
||||
showPreview,
|
||||
]);
|
||||
|
||||
function update(key: string, value: string | number | null) {
|
||||
@@ -404,16 +409,33 @@ function InlineEditorSession({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={pageHref} target="_blank">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("fullEditor")}
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
{catalogType === "normal" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 gap-1 text-xs",
|
||||
showPreview &&
|
||||
"border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] text-[var(--admin-info)]",
|
||||
)}
|
||||
onClick={() => setShowPreview((v) => !v)}
|
||||
>
|
||||
<MonitorPlay className="h-3 w-3" />
|
||||
{t("preview.title")}
|
||||
</Button>
|
||||
)}
|
||||
<Link href={pageHref} target="_blank">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("fullEditor")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dirty banner */}
|
||||
@@ -438,394 +460,404 @@ function InlineEditorSession({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(tab) => {
|
||||
if (tab === activeTab) return;
|
||||
if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return;
|
||||
setOffersDirty(false);
|
||||
setActiveTab(tab);
|
||||
}}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="mx-4 mt-2 shrink-0">
|
||||
<TabsTrigger value="settings" className="gap-1.5 text-xs">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
{isDirty && (
|
||||
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="items" className="gap-1.5 text-xs">
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
Items
|
||||
{itemsData && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[9px] ml-0.5">
|
||||
{itemsData.items.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="translate" className="gap-1.5 text-xs">
|
||||
<Languages className="h-3.5 w-3.5" />
|
||||
Translate
|
||||
{itemsData &&
|
||||
(() => {
|
||||
const furniSet = new Set(itemsData.furniDataIdList);
|
||||
const missing = itemsData.baseItems.filter(
|
||||
(b) => !furniSet.has(b.id),
|
||||
).length;
|
||||
return missing > 0 ? (
|
||||
<span className="ml-0.5 rounded-full bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] text-[9px] px-1 py-0.5 leading-none font-medium">
|
||||
{missing}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Settings tab ──────────────────────────────── */}
|
||||
<TabsContent
|
||||
value="settings"
|
||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
||||
{/* Editor + live preview */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(tab) => {
|
||||
if (tab === activeTab) return;
|
||||
if (offersDirty && !window.confirm(tActions("leaveUnsaved")))
|
||||
return;
|
||||
setOffersDirty(false);
|
||||
setActiveTab(tab);
|
||||
}}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Caption + Layout */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Caption</Label>
|
||||
<Input
|
||||
value={String(form.caption || "")}
|
||||
onChange={(e) => update("caption", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Layout</Label>
|
||||
<Select
|
||||
value={String(form.pageLayout || "")}
|
||||
onValueChange={(v) => update("pageLayout", v)}
|
||||
<TabsList className="mx-4 mt-2 shrink-0">
|
||||
<TabsTrigger value="settings" className="gap-1.5 text-xs">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
{isDirty && (
|
||||
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="items" className="gap-1.5 text-xs">
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
Items
|
||||
{itemsData && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 px-1 text-[9px] ml-0.5"
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATALOG_LAYOUTS.map((l) => {
|
||||
const color =
|
||||
LAYOUT_COLORS[l] || "bg-muted text-muted-foreground";
|
||||
return (
|
||||
<SelectItem key={l} value={l}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
color,
|
||||
)}
|
||||
/>
|
||||
{l}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{LAYOUT_DESCRIPTIONS[
|
||||
String(form.pageLayout) as CatalogLayout
|
||||
] ?? "Custom layout"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order + Rank + Icon */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.orderNum as number}
|
||||
onChange={(e) =>
|
||||
update("orderNum", parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{catalogType === "normal" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Min Rank</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.minRank as number}
|
||||
onChange={(e) =>
|
||||
update("minRank", parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Icon</Label>
|
||||
<IconPicker
|
||||
value={(form.iconImage as number) ?? 0}
|
||||
onChange={(v) => update("iconImage", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className={styles.options}>
|
||||
<ToggleRow
|
||||
id="ie-enabled"
|
||||
disabled={!canEdit || saving}
|
||||
label={t("access.enabled")}
|
||||
description={t("access.enabledHint")}
|
||||
checked={form.enabled === "1"}
|
||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="ie-visible"
|
||||
disabled={!canEdit || saving}
|
||||
label={t("access.visible")}
|
||||
description={t("access.visibleHint")}
|
||||
checked={form.visible === "1"}
|
||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||
/>
|
||||
{catalogType === "normal" && (
|
||||
<ToggleRow
|
||||
id="ie-club"
|
||||
disabled={!canEdit || saving}
|
||||
label={t("access.club")}
|
||||
description={t("access.clubHint")}
|
||||
checked={form.clubOnly === "1"}
|
||||
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headline image picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Headline</Label>
|
||||
<CatalogImagePicker
|
||||
value={String(form.pageHeadline || "")}
|
||||
onChange={(v) => update("pageHeadline", v)}
|
||||
type="header"
|
||||
label="Headline image"
|
||||
/>
|
||||
<ImagePreview
|
||||
value={String(form.pageHeadline || "")}
|
||||
height={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Teaser image picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Teaser</Label>
|
||||
<CatalogImagePicker
|
||||
value={String(form.pageTeaser || "")}
|
||||
onChange={(v) => update("pageTeaser", v)}
|
||||
type="teaser"
|
||||
label="Teaser image"
|
||||
/>
|
||||
<ImagePreview value={String(form.pageTeaser || "")} height={50} />
|
||||
</div>
|
||||
|
||||
{/* Collapsible text fields */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTexts(!showTexts)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showTexts ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Text fields
|
||||
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
|
||||
has content
|
||||
{itemsData.items.length}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="translate" className="gap-1.5 text-xs">
|
||||
<Languages className="h-3.5 w-3.5" />
|
||||
Translate
|
||||
{itemsData &&
|
||||
(() => {
|
||||
const furniSet = new Set(itemsData.furniDataIdList);
|
||||
const missing = itemsData.baseItems.filter(
|
||||
(b) => !furniSet.has(b.id),
|
||||
).length;
|
||||
return missing > 0 ? (
|
||||
<span className="ml-0.5 rounded-full bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] text-[9px] px-1 py-0.5 leading-none font-medium">
|
||||
{missing}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{showTexts && (
|
||||
<div className="space-y-3 pl-2 border-l-2 border-border/40">
|
||||
{/* ── Settings tab ──────────────────────────────── */}
|
||||
<TabsContent
|
||||
value="settings"
|
||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Caption + Layout */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text 1</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageText1 || "")}
|
||||
onChange={(e) =>
|
||||
update("pageText1", e.target.value || null)
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text 2</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageText2 || "")}
|
||||
onChange={(e) =>
|
||||
update("pageText2", e.target.value || null)
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text Details</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageTextDetails || "")}
|
||||
onChange={(e) =>
|
||||
update("pageTextDetails", e.target.value || null)
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Special</Label>
|
||||
<Label className="text-xs">Caption</Label>
|
||||
<Input
|
||||
value={String(form.pageSpecial || "")}
|
||||
value={String(form.caption || "")}
|
||||
onChange={(e) => update("caption", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Layout</Label>
|
||||
<Select
|
||||
value={String(form.pageLayout || "")}
|
||||
onValueChange={(v) => update("pageLayout", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATALOG_LAYOUTS.map((l) => {
|
||||
const color =
|
||||
LAYOUT_COLORS[l] || "bg-muted text-muted-foreground";
|
||||
return (
|
||||
<SelectItem key={l} value={l}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
color,
|
||||
)}
|
||||
/>
|
||||
{l}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{LAYOUT_DESCRIPTIONS[
|
||||
String(form.pageLayout) as CatalogLayout
|
||||
] ?? "Custom layout"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order + Rank + Icon */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.orderNum as number}
|
||||
onChange={(e) =>
|
||||
update("pageSpecial", e.target.value || null)
|
||||
update("orderNum", parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isDirty || !canEdit}
|
||||
className="w-full"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{tNavigation("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Items tab ─────────────────────────────────── */}
|
||||
<TabsContent
|
||||
value="items"
|
||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
||||
>
|
||||
{catalogType === "bc" ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
|
||||
<p>BC items are managed on the full Builder Club page editor.</p>
|
||||
<Link href={pageHref} target="_blank">
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open BC page editor
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : itemsError && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Button variant="outline" onClick={reloadItems}>
|
||||
{t("retryItems")}
|
||||
</Button>
|
||||
</div>
|
||||
) : itemsLoading && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : itemsData ? (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canEdit && (
|
||||
<>
|
||||
<QuickAddFurni
|
||||
pageId={page.id}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
{catalogType === "normal" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Min Rank</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.minRank as number}
|
||||
onChange={(e) =>
|
||||
update("minRank", parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<BulkImportItems
|
||||
pageId={page.id}
|
||||
onImported={reloadItems}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="ml-auto"
|
||||
>
|
||||
{showPreview ? "Hide Preview" : "Shop Preview"}
|
||||
</Button>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Icon</Label>
|
||||
<IconPicker
|
||||
value={(form.iconImage as number) ?? 0}
|
||||
onChange={(v) => update("iconImage", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shop preview (togglable) */}
|
||||
{showPreview && (
|
||||
<ItemsShopPreview
|
||||
items={itemsData.items}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
layout={page.pageLayout}
|
||||
canEdit={canEdit}
|
||||
{/* Toggles */}
|
||||
<div className={styles.options}>
|
||||
<ToggleRow
|
||||
id="ie-enabled"
|
||||
disabled={!canEdit || saving}
|
||||
label={t("access.enabled")}
|
||||
description={t("access.enabledHint")}
|
||||
checked={form.enabled === "1"}
|
||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="ie-visible"
|
||||
disabled={!canEdit || saving}
|
||||
label={t("access.visible")}
|
||||
description={t("access.visibleHint")}
|
||||
checked={form.visible === "1"}
|
||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||
/>
|
||||
{catalogType === "normal" && (
|
||||
<ToggleRow
|
||||
id="ie-club"
|
||||
disabled={!canEdit || saving}
|
||||
label={t("access.club")}
|
||||
description={t("access.clubHint")}
|
||||
checked={form.clubOnly === "1"}
|
||||
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headline image picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Headline</Label>
|
||||
<CatalogImagePicker
|
||||
value={String(form.pageHeadline || "")}
|
||||
onChange={(v) => update("pageHeadline", v)}
|
||||
type="header"
|
||||
label="Headline image"
|
||||
/>
|
||||
<ImagePreview
|
||||
value={String(form.pageHeadline || "")}
|
||||
height={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Teaser image picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Teaser</Label>
|
||||
<CatalogImagePicker
|
||||
value={String(form.pageTeaser || "")}
|
||||
onChange={(v) => update("pageTeaser", v)}
|
||||
type="teaser"
|
||||
label="Teaser image"
|
||||
/>
|
||||
<ImagePreview
|
||||
value={String(form.pageTeaser || "")}
|
||||
height={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collapsible text fields */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTexts(!showTexts)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showTexts ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Text fields
|
||||
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
|
||||
has content
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showTexts && (
|
||||
<div className="space-y-3 pl-2 border-l-2 border-border/40">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text 1</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageText1 || "")}
|
||||
onChange={(e) =>
|
||||
update("pageText1", e.target.value || null)
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text 2</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageText2 || "")}
|
||||
onChange={(e) =>
|
||||
update("pageText2", e.target.value || null)
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text Details</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageTextDetails || "")}
|
||||
onChange={(e) =>
|
||||
update("pageTextDetails", e.target.value || null)
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Special</Label>
|
||||
<Input
|
||||
value={String(form.pageSpecial || "")}
|
||||
onChange={(e) =>
|
||||
update("pageSpecial", e.target.value || null)
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items table */}
|
||||
<CatalogItemsTable
|
||||
initialSearch={
|
||||
initialOfferId ? `#${initialOfferId}` : undefined
|
||||
}
|
||||
onDirtyChange={setOffersDirty}
|
||||
onRefresh={reloadItems}
|
||||
items={itemsData.items}
|
||||
pageId={page.id}
|
||||
pageLayout={page.pageLayout}
|
||||
canEdit={canEdit}
|
||||
interactionTypes={itemsData.interactionTypes}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
allPages={itemsData.allPages}
|
||||
/>
|
||||
{/* Save button */}
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isDirty || !canEdit}
|
||||
className="w-full"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{tNavigation("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Translate tab ─────────────────────────────── */}
|
||||
<TabsContent
|
||||
value="translate"
|
||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
||||
>
|
||||
{catalogType === "bc" ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Translate tools are available for the shop catalog only.
|
||||
</div>
|
||||
) : itemsError && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Button variant="outline" onClick={reloadItems}>
|
||||
{t("retryItems")}
|
||||
</Button>
|
||||
</div>
|
||||
) : itemsLoading && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : itemsData ? (
|
||||
<CatalogTranslateTab
|
||||
baseItems={itemsData.baseItems}
|
||||
catalogNameMap={itemsData.catalogNameMap}
|
||||
canEdit={canEdit}
|
||||
furniDataIdList={itemsData.furniDataIdList}
|
||||
furniDescriptionMap={itemsData.furniDescriptionMap}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
gamedataHotel={itemsData.gamedataHotel}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* ── Items tab ─────────────────────────────────── */}
|
||||
<TabsContent
|
||||
value="items"
|
||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
||||
>
|
||||
{catalogType === "bc" ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
|
||||
<p>
|
||||
BC items are managed on the full Builder Club page editor.
|
||||
</p>
|
||||
<Link href={pageHref} target="_blank">
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open BC page editor
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : itemsError && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Button variant="outline" onClick={reloadItems}>
|
||||
{t("retryItems")}
|
||||
</Button>
|
||||
</div>
|
||||
) : itemsLoading && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : itemsData ? (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canEdit && (
|
||||
<>
|
||||
<QuickAddFurni
|
||||
pageId={page.id}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
/>
|
||||
<BulkImportItems
|
||||
pageId={page.id}
|
||||
onImported={reloadItems}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AutoOrganize
|
||||
items={itemsData.items}
|
||||
currentLayout={page.pageLayout}
|
||||
canEdit={canEdit}
|
||||
onSorted={reloadItems}
|
||||
onApplyLayout={(layout) => update("pageLayout", layout)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Items table */}
|
||||
<CatalogItemsTable
|
||||
initialSearch={
|
||||
initialOfferId ? `#${initialOfferId}` : undefined
|
||||
}
|
||||
onDirtyChange={setOffersDirty}
|
||||
onRefresh={reloadItems}
|
||||
items={itemsData.items}
|
||||
pageId={page.id}
|
||||
pageLayout={page.pageLayout}
|
||||
canEdit={canEdit}
|
||||
interactionTypes={itemsData.interactionTypes}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
allPages={itemsData.allPages}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Translate tab ─────────────────────────────── */}
|
||||
<TabsContent
|
||||
value="translate"
|
||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
||||
>
|
||||
{catalogType === "bc" ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Translate tools are available for the shop catalog only.
|
||||
</div>
|
||||
) : itemsError && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Button variant="outline" onClick={reloadItems}>
|
||||
{t("retryItems")}
|
||||
</Button>
|
||||
</div>
|
||||
) : itemsLoading && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : itemsData ? (
|
||||
<CatalogTranslateTab
|
||||
baseItems={itemsData.baseItems}
|
||||
catalogNameMap={itemsData.catalogNameMap}
|
||||
canEdit={canEdit}
|
||||
furniDataIdList={itemsData.furniDataIdList}
|
||||
furniDescriptionMap={itemsData.furniDescriptionMap}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
gamedataHotel={itemsData.gamedataHotel}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{catalogType === "normal" && showPreview && (
|
||||
<LiveCatalogPreview
|
||||
form={form}
|
||||
items={itemsData?.items ?? null}
|
||||
furniRevisionMap={itemsData?.furniRevisionMap ?? {}}
|
||||
loadingItems={itemsLoading}
|
||||
onApplyLayout={(layout) => update("pageLayout", layout)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
MonitorPlay,
|
||||
PackageX,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import {
|
||||
getShopGridClass,
|
||||
getShopTileSize,
|
||||
parseFirstItemId,
|
||||
ShopTile,
|
||||
} from "@/components/admin/catalog/shop-tile";
|
||||
import { ImagePreview } from "@/components/admin/catalog-manager/image-preview";
|
||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { suggestCatalogLayout } from "@/features/catalog/domain/organize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LivePageForm {
|
||||
caption?: string | number | null;
|
||||
pageLayout?: string | number | null;
|
||||
pageHeadline?: string | number | null;
|
||||
pageTeaser?: string | number | null;
|
||||
pageText1?: string | number | null;
|
||||
pageText2?: string | number | null;
|
||||
pageTextDetails?: string | number | null;
|
||||
pageSpecial?: string | number | null;
|
||||
pageTextTeaser?: string | number | null;
|
||||
iconImage?: string | number | null;
|
||||
enabled?: string | number | null;
|
||||
visible?: string | number | null;
|
||||
}
|
||||
|
||||
interface LiveCatalogPreviewProps {
|
||||
form: LivePageForm;
|
||||
items?: CatalogItemData[] | null;
|
||||
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
|
||||
loadingItems?: boolean;
|
||||
className?: string;
|
||||
onApplyLayout?: (layout: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-live shop preview of a catalog page: reflects the current editor
|
||||
* form state (title, layout, images, texts) plus the page's offers so the
|
||||
* staff member sees exactly how the page is organized for players.
|
||||
*/
|
||||
export function LiveCatalogPreview({
|
||||
form,
|
||||
items = [],
|
||||
furniRevisionMap = {},
|
||||
loadingItems,
|
||||
className,
|
||||
onApplyLayout,
|
||||
}: LiveCatalogPreviewProps) {
|
||||
const t = useTranslations("pages.admin.catalog.workspace.preview");
|
||||
const layout = String(form.pageLayout || "default_3x3");
|
||||
const caption = String(form.caption || "");
|
||||
const isEnabled = String(form.enabled ?? "1") === "1";
|
||||
const isVisible = String(form.visible ?? "1") === "1";
|
||||
|
||||
const itemsList = items ?? [];
|
||||
const suggestion =
|
||||
itemsList.length > 0 ? suggestCatalogLayout(itemsList) : null;
|
||||
const showSuggestion =
|
||||
suggestion !== null && suggestion.layout !== layout && onApplyLayout;
|
||||
|
||||
const gridClass = cn(
|
||||
"grid gap-2",
|
||||
layout === "frontpage" ||
|
||||
layout === "single_bundle" ||
|
||||
layout === "room_bundle"
|
||||
? "grid-cols-1"
|
||||
: getShopGridClass(layout).includes("grid-cols-1")
|
||||
? "grid-cols-1"
|
||||
: "grid-cols-2",
|
||||
);
|
||||
const tileSize = Math.min(getShopTileSize(layout), 88);
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={t("label")}
|
||||
className={cn(
|
||||
"flex min-h-0 w-80 shrink-0 flex-col overflow-hidden border-l border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Panel header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold">
|
||||
<MonitorPlay className="h-3.5 w-3.5" />
|
||||
{t("title")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<PreviewStatusDot on={isEnabled}>
|
||||
{t(isEnabled ? "on" : "off")}
|
||||
</PreviewStatusDot>
|
||||
<PreviewStatusDot on={isVisible}>
|
||||
{isVisible ? (
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<EyeOff className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</PreviewStatusDot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{/* Auto-organize hints */}
|
||||
{showSuggestion && suggestion && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-2 text-[11px] text-[var(--admin-warning)]">
|
||||
<Wand2 className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{t("suggestedLayout")}</p>
|
||||
<p className="break-words text-[var(--admin-text-muted)]">
|
||||
{t("suggestedLayoutReason", {
|
||||
layout: suggestion.layout,
|
||||
reason: suggestion.reason,
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1.5 h-6 text-[11px] px-2"
|
||||
onClick={() => onApplyLayout(suggestion.layout)}
|
||||
>
|
||||
{t("applyLayout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEnabled && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-2 text-[11px] text-[var(--admin-warning)]">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
{t("disabledHint")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Game-catalog window */}
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] shadow-sm">
|
||||
{/* Window title bar */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-2.5 py-1.5">
|
||||
<CatalogIcon iconImage={Number(form.iconImage) || 0} size={16} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold">
|
||||
{caption || t("untitled")}
|
||||
</span>
|
||||
<Badge
|
||||
className={cn(
|
||||
"h-4 border-0 px-1 font-mono text-[8px]",
|
||||
LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{layout.replace("default_3x3", "3x3")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Main body */}
|
||||
<div
|
||||
className="p-2.5"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"var(--admin-surface-muted, var(--admin-canvas))",
|
||||
}}
|
||||
>
|
||||
{/* Headline banner */}
|
||||
{String(form.pageHeadline || "") && (
|
||||
<div className="mb-2">
|
||||
<ImagePreview
|
||||
value={String(form.pageHeadline)}
|
||||
height={56}
|
||||
className="w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teaser row */}
|
||||
{String(form.pageTeaser || "") && (
|
||||
<div className="mb-2">
|
||||
<ImagePreview
|
||||
value={String(form.pageTeaser)}
|
||||
height={48}
|
||||
className="w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text blocks */}
|
||||
{String(form.pageText1 || "") && (
|
||||
<p className="mb-1 text-[11px] leading-snug break-words" key="p1">
|
||||
{String(form.pageText1)}
|
||||
</p>
|
||||
)}
|
||||
{String(form.pageText2 || "") && (
|
||||
<p
|
||||
className="mb-1 text-[11px] text-[var(--admin-text-muted)] leading-snug break-words"
|
||||
key="p2"
|
||||
>
|
||||
{String(form.pageText2)}
|
||||
</p>
|
||||
)}
|
||||
{String(form.pageTextDetails || "") && (
|
||||
<p
|
||||
className="mb-2 text-[10px] text-[var(--admin-text-muted)] break-words"
|
||||
key="pd"
|
||||
>
|
||||
{String(form.pageTextDetails)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Offers */}
|
||||
{loadingItems && !items ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-[var(--admin-text-muted)]" />
|
||||
</div>
|
||||
) : itemsList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border-2 border-dashed border-[var(--admin-border)] py-8 text-center">
|
||||
<PackageX className="h-7 w-7 text-[var(--admin-text-muted)]/40" />
|
||||
<p className="mt-1.5 text-[11px] text-[var(--admin-text-muted)]">
|
||||
{t("noItems")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={gridClass}>
|
||||
{itemsList.map((item) => {
|
||||
const firstId = parseFirstItemId(item.itemIds);
|
||||
const furni =
|
||||
firstId != null ? furniRevisionMap[firstId] : undefined;
|
||||
return (
|
||||
<ShopTile
|
||||
key={item.id}
|
||||
item={item}
|
||||
furni={furni}
|
||||
iconHeight={tileSize}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[10px] text-[var(--admin-text-muted)]">
|
||||
{itemsList.length} {t("items")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewStatusDot({
|
||||
on,
|
||||
children,
|
||||
}: {
|
||||
on: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-medium leading-none",
|
||||
on
|
||||
? "bg-[var(--admin-success-subtle)] text-[var(--admin-success)]"
|
||||
: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
"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 { Package } from "lucide-react";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getShopGridClass,
|
||||
getShopTileSize,
|
||||
parseFirstItemId,
|
||||
ShopTile,
|
||||
} from "./shop-tile";
|
||||
|
||||
interface ItemsShopPreviewProps {
|
||||
items: CatalogItemData[];
|
||||
@@ -39,8 +36,8 @@ export function ItemsShopPreview({
|
||||
);
|
||||
}
|
||||
|
||||
const gridClass = getGridClass(layout);
|
||||
const tileSize = getTileSize(layout);
|
||||
const gridClass = getShopGridClass(layout);
|
||||
const tileSize = getShopTileSize(layout);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
|
||||
@@ -52,183 +49,19 @@ export function ItemsShopPreview({
|
||||
{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
|
||||
<ShopTile
|
||||
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>
|
||||
item={item}
|
||||
furni={furni}
|
||||
iconHeight={tileSize}
|
||||
canEdit={canEdit}
|
||||
onClick={onItemClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { Crown, Package, Sparkles } from "lucide-react";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import { 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";
|
||||
|
||||
/** Grid + tile sizing per page layout, shared by shop previews. */
|
||||
export function getShopGridClass(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";
|
||||
}
|
||||
}
|
||||
|
||||
export function getShopTileSize(layout: string): number {
|
||||
switch (layout) {
|
||||
case "frontpage":
|
||||
case "single_bundle":
|
||||
case "room_bundle":
|
||||
return 140;
|
||||
case "spaces_new":
|
||||
return 110;
|
||||
default:
|
||||
return 96;
|
||||
}
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
interface ShopTileProps {
|
||||
item: CatalogItemData;
|
||||
furni?: { classname: string; revision: number } | null;
|
||||
/** Pixel height of the icon area. Defaults to a small standard tile. */
|
||||
iconHeight?: number;
|
||||
canEdit?: boolean;
|
||||
onClick?: (item: CatalogItemData) => void;
|
||||
}
|
||||
|
||||
/** One purchasable offer tile exactly as it appears in the shop. */
|
||||
export function ShopTile({
|
||||
item,
|
||||
furni,
|
||||
iconHeight = 96,
|
||||
canEdit,
|
||||
onClick,
|
||||
}: ShopTileProps) {
|
||||
const points = getPointsLabel(item.pointsType);
|
||||
const isClub = item.clubOnly === "1";
|
||||
const isOffer = item.haveOffer === "1" && item.offerId > 0;
|
||||
const isLimited = item.limitedStack > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(item)}
|
||||
disabled={!canEdit && !onClick}
|
||||
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: iconHeight }}
|
||||
>
|
||||
<ShopFurniIcon
|
||||
classname={furni?.classname ?? ""}
|
||||
revision={furni?.revision ?? 0}
|
||||
size={Math.floor(iconHeight * 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 */}
|
||||
{isLimited && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shop tile furni icon (resizable) ────────────────────────────
|
||||
|
||||
export function ShopFurniIcon({
|
||||
classname,
|
||||
revision,
|
||||
size,
|
||||
}: {
|
||||
classname: string;
|
||||
revision: number;
|
||||
size: number;
|
||||
}) {
|
||||
if (!classname) {
|
||||
return (
|
||||
<Package
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user