Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m23s
A bulk offer edit is the catalog mutation that rewrites hundreds of rows at once, and it was the only one writing nothing to the staff activity log: 22 of the 43 catalog actions logged, this one did not. The entry it now writes says what changed, not just that something did, because the log has no undo of its own and "bulk updated 200 offers" cannot answer the question it exists for. Deleting offers had no inverse at all. Every removed row is now kept at delete time and the caller gets a restoreId back, so an accidental multi-select is a click rather than a hand-edit of the table. The undo toast covers the common case; a RecentDeletionsPanel holds the same records so a delete noticed later is still reachable. Three refusals guard it: an id that another offer has since taken, a category that no longer exists (which would leave an offer that sells nowhere and shows under no page), and a delete whose restore record cannot be written — that one rolls back rather than deleting without a way back. Reading the audit row FOR UPDATE is also what stops two restores of one deletion from both inserting. sendCatalogUpdate() overwrote hotel-status.json on every write, so "which imports reached the hotel" was answerable for the last attempt only, and a failure two imports ago was gone by the time anyone looked. That file is now also appended to as a bounded 50-entry tail. The tree route carried four copies of the same page-select-plus-counts shaping, of which the BC branches had already drifted: one counted offers through the VARCHAR-tolerant helper, the other inline and swallowing errors. All of it is one readPages() now, and readFullTree sends both catalogs through one depth computation instead of delegating normal to getTreeFlat while computing BC here — a split that left two implementations behind one function name. getTreeFlat is gone. The BC ancestor walk also went from 20 levels to 50, matching getAncestors, so a deeply nested catalog no longer loses its breadcrumb. Bulk editing reaches the BC catalog, which previously had no way to edit or duplicate offers in bulk. The catalog is part of the operation identity now, so replaying one request key against the other catalog is not mistaken for the same work. Integration tests failed to import: the next/cache mock supplied only revalidatePath, and catalog-totals calls unstable_cache at module scope.
929 lines
28 KiB
TypeScript
929 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
AlertCircle,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Languages,
|
|
Loader2,
|
|
MonitorPlay,
|
|
Package,
|
|
Save,
|
|
Settings,
|
|
} from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { updateCatalogPage } from "@/actions/catalog";
|
|
import { updateBcPage } from "@/actions/catalog-bc";
|
|
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
|
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
|
import {
|
|
type CatalogItemData,
|
|
CatalogItemsTable,
|
|
} from "@/components/admin/catalog/catalog-items-table";
|
|
import { CatalogTranslateTab } from "@/components/admin/catalog/catalog-translate-tab";
|
|
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
|
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
|
|
import { AutoOrganize } from "@/components/admin/catalog-manager/auto-organize";
|
|
import { BcItemsEditor } from "@/components/admin/catalog-manager/bc-items-editor";
|
|
import { LiveCatalogPreview } from "@/components/admin/catalog-manager/live-catalog-preview";
|
|
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
|
|
import {
|
|
CATALOG_LAYOUTS,
|
|
type CatalogLayout,
|
|
LAYOUT_DESCRIPTIONS,
|
|
} from "@/lib/catalog-layouts";
|
|
import { cn } from "@/lib/utils";
|
|
import styles from "./catalog-access.module.css";
|
|
import { ImagePreview } from "./image-preview";
|
|
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
|
|
|
|
interface PageDetail {
|
|
id: number;
|
|
caption: string;
|
|
parentId: number;
|
|
pageLayout: string;
|
|
enabled: string;
|
|
visible: string;
|
|
minRank: number;
|
|
clubOnly: string;
|
|
orderNum: number;
|
|
iconImage: number;
|
|
iconColor: number;
|
|
pageHeadline: string;
|
|
pageTeaser: string;
|
|
pageSpecial: string | null;
|
|
pageText1: string | null;
|
|
pageText2: string | null;
|
|
pageTextDetails: string | null;
|
|
pageTextTeaser: string | null;
|
|
itemCount?: number;
|
|
}
|
|
|
|
interface ItemsData {
|
|
items: CatalogItemData[];
|
|
baseItems: {
|
|
id: number;
|
|
publicName: string;
|
|
itemName: string;
|
|
spriteId: number;
|
|
type: string;
|
|
}[];
|
|
catalogNameMap: Record<number, string>;
|
|
furniDataIdList: number[];
|
|
furniDescriptionMap: Record<number, string>;
|
|
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
|
interactionTypes: string[];
|
|
allPages: { id: number; caption: string }[];
|
|
gamedataHotel?: string;
|
|
}
|
|
|
|
export interface PageUpdateData {
|
|
id: number;
|
|
caption: string;
|
|
enabled: string;
|
|
visible: string;
|
|
pageLayout: string;
|
|
iconImage: number;
|
|
}
|
|
|
|
interface InlineEditorProps {
|
|
initialOfferId?: number | null;
|
|
pageId: number | null;
|
|
onSaved: (updated: PageUpdateData) => void;
|
|
}
|
|
|
|
type FormState = Record<string, string | number | null>;
|
|
|
|
export function InlineEditor(props: InlineEditorProps) {
|
|
const { catalogType } = useCatalogManager();
|
|
return (
|
|
<InlineEditorSession key={`${catalogType}:${props.pageId}`} {...props} />
|
|
);
|
|
}
|
|
|
|
function InlineEditorSession({
|
|
pageId,
|
|
onSaved,
|
|
initialOfferId,
|
|
}: InlineEditorProps) {
|
|
const pageRequests = useLatestRequest();
|
|
const itemRequests = useLatestRequest();
|
|
const saveRequests = useLatestRequest();
|
|
const tActions = useTranslations("pages.admin.actions");
|
|
const tNavigation = useTranslations("pages.admin.navigation");
|
|
const t = useTranslations("pages.admin.catalog.workspace");
|
|
const [offersDirty, setOffersDirty] = useState(false);
|
|
const { dispatch, catalogType, canEdit } = useCatalogManager();
|
|
const catQs = catalogTreeQs(catalogType);
|
|
const [page, setPage] = useState<PageDetail | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [justSaved, setJustSaved] = useState(false);
|
|
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (savedTimer.current) clearTimeout(savedTimer.current);
|
|
},
|
|
[],
|
|
);
|
|
const [form, setForm] = useState<FormState>({});
|
|
const [original, setOriginal] = useState<FormState>({});
|
|
const [showTexts, setShowTexts] = useState(false);
|
|
const [activeTab, setActiveTab] = useState(
|
|
initialOfferId ? "items" : "settings",
|
|
);
|
|
const [showPreview, setShowPreview] = useState(catalogType === "normal");
|
|
|
|
// Items/translate data (lazy loaded)
|
|
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
|
|
const [itemsLoading, setItemsLoading] = useState(false);
|
|
const [itemsLoaded, setItemsLoaded] = useState(false);
|
|
const [itemsError, setItemsError] = useState(false);
|
|
|
|
// ── Dirty tracking ──────────────────────────────────────────
|
|
const isDirty = useMemo(() => {
|
|
return Object.keys(form).some((k) => form[k] !== original[k]);
|
|
}, [form, original]);
|
|
|
|
useEffect(() => {
|
|
dispatch({ type: "SET_DIRTY", dirty: isDirty || offersDirty });
|
|
}, [isDirty, offersDirty, dispatch]);
|
|
|
|
// ── Ctrl+S handler ──────────────────────────────────────────
|
|
useEffect(() => {
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
if (canEdit && isDirty && !saving && page) handleSave();
|
|
}
|
|
}
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent
|
|
}, [canEdit, isDirty, saving, page, handleSave]);
|
|
|
|
const loadPage = useCallback(
|
|
async (id: number) => {
|
|
const request = pageRequests.start();
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
|
|
{ signal: request.signal },
|
|
);
|
|
if (!res.ok) throw new Error();
|
|
const data = await res.json();
|
|
if (!request.isCurrent()) return;
|
|
const p = data.page as PageDetail;
|
|
setPage(p);
|
|
const formData: FormState = {
|
|
caption: p.caption,
|
|
pageLayout: p.pageLayout,
|
|
enabled: String(p.enabled),
|
|
visible: String(p.visible),
|
|
...(catalogType === "normal"
|
|
? { minRank: p.minRank ?? 0, clubOnly: String(p.clubOnly ?? "0") }
|
|
: {}),
|
|
orderNum: p.orderNum,
|
|
iconImage: p.iconImage,
|
|
iconColor: p.iconColor,
|
|
pageHeadline: p.pageHeadline || "",
|
|
pageTeaser: p.pageTeaser || "",
|
|
pageSpecial: p.pageSpecial || "",
|
|
pageText1: p.pageText1 || "",
|
|
pageText2: p.pageText2 || "",
|
|
pageTextDetails: p.pageTextDetails || "",
|
|
pageTextTeaser: p.pageTextTeaser || "",
|
|
};
|
|
setForm(formData);
|
|
setOriginal(formData);
|
|
} catch {
|
|
if (!request.isCurrent()) return;
|
|
toast.error("Failed to load page");
|
|
setPage(null);
|
|
} finally {
|
|
if (request.isCurrent()) setLoading(false);
|
|
}
|
|
},
|
|
[catQs, catalogType, pageRequests],
|
|
);
|
|
|
|
const loadItemsData = useCallback(
|
|
async (id: number) => {
|
|
const request = itemRequests.start();
|
|
setItemsError(false);
|
|
setItemsLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`, {
|
|
signal: request.signal,
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
const data = await res.json();
|
|
if (!request.isCurrent()) return;
|
|
setItemsData(data as ItemsData);
|
|
setItemsLoaded(true);
|
|
} catch {
|
|
if (!request.isCurrent()) return;
|
|
setItemsLoaded(true);
|
|
setItemsError(true);
|
|
toast.error("Failed to load items");
|
|
} finally {
|
|
if (request.isCurrent()) setItemsLoading(false);
|
|
}
|
|
},
|
|
[itemRequests],
|
|
);
|
|
|
|
const reloadItems = useCallback(() => {
|
|
if (pageId && pageId > 0) loadItemsData(pageId);
|
|
}, [pageId, loadItemsData]);
|
|
|
|
useEffect(() => {
|
|
if (pageId && pageId > 0) {
|
|
loadPage(pageId);
|
|
setItemsData(null);
|
|
setItemsLoaded(false);
|
|
setActiveTab(initialOfferId ? "items" : "settings");
|
|
setShowPreview(false);
|
|
} else {
|
|
setPage(null);
|
|
setForm({});
|
|
setOriginal({});
|
|
setItemsData(null);
|
|
setItemsLoaded(false);
|
|
}
|
|
}, [pageId, loadPage, initialOfferId]);
|
|
|
|
// 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" || showPreview) &&
|
|
!itemsLoaded &&
|
|
!itemsLoading &&
|
|
pageId &&
|
|
pageId > 0
|
|
) {
|
|
loadItemsData(pageId);
|
|
}
|
|
}, [
|
|
activeTab,
|
|
itemsLoaded,
|
|
itemsLoading,
|
|
pageId,
|
|
loadItemsData,
|
|
catalogType,
|
|
showPreview,
|
|
]);
|
|
|
|
function update(key: string, value: string | number | null) {
|
|
if (!canEdit) return;
|
|
setForm((prev) => ({ ...prev, [key]: value }));
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!page || !canEdit || saving) return;
|
|
const request = saveRequests.start();
|
|
setSaving(true);
|
|
try {
|
|
const result =
|
|
catalogType === "bc"
|
|
? await updateBcPage({ id: page.id, ...form, expected: original })
|
|
: await updateCatalogPage({
|
|
id: page.id,
|
|
...form,
|
|
expected: original,
|
|
});
|
|
if (!request.isCurrent()) return;
|
|
if (!result.ok) throw new Error(result.error || "Failed to save");
|
|
toast.success(tActions("saved"));
|
|
setJustSaved(true);
|
|
if (savedTimer.current) clearTimeout(savedTimer.current);
|
|
savedTimer.current = setTimeout(() => setJustSaved(false), 2500);
|
|
setOriginal({ ...form });
|
|
setPage((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
caption: String(form.caption || prev.caption),
|
|
enabled: String(form.enabled ?? prev.enabled),
|
|
visible: String(form.visible ?? prev.visible),
|
|
pageLayout: String(form.pageLayout || prev.pageLayout),
|
|
iconImage: (form.iconImage as number) ?? prev.iconImage,
|
|
}
|
|
: prev,
|
|
);
|
|
onSaved({
|
|
id: page.id,
|
|
caption: String(form.caption || page.caption),
|
|
enabled: String(form.enabled ?? page.enabled),
|
|
visible: String(form.visible ?? page.visible),
|
|
pageLayout: String(form.pageLayout || page.pageLayout),
|
|
iconImage: (form.iconImage as number) ?? page.iconImage,
|
|
});
|
|
} catch (e) {
|
|
if (request.isCurrent())
|
|
toast.error(e instanceof Error ? e.message : "Failed to save");
|
|
} finally {
|
|
if (request.isCurrent()) setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (!pageId || pageId <= 0) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
|
Select a page from the tree to edit
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!page)
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<Button variant="outline" onClick={() => loadPage(pageId)}>
|
|
{t("retryPage")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
const layoutColor =
|
|
LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground";
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<CatalogIcon
|
|
iconImage={(form.iconImage as number) ?? page.iconImage}
|
|
size={24}
|
|
/>
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="text-sm font-semibold">
|
|
{(form.caption as string) || page.caption}
|
|
</h3>
|
|
<Badge
|
|
className={cn(
|
|
"h-4 px-1 text-[8px] font-mono border-0",
|
|
layoutColor,
|
|
)}
|
|
>
|
|
{form.pageLayout as string}
|
|
</Badge>
|
|
{saving ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-4 px-1.5 text-[9px] text-[var(--admin-info)] border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)]"
|
|
>
|
|
<Loader2 className="h-2.5 w-2.5 mr-0.5 animate-spin" />
|
|
{tActions("saving")}
|
|
</Badge>
|
|
) : justSaved && !isDirty && !offersDirty ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-4 px-1.5 text-[9px] text-[var(--admin-success)] border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]"
|
|
>
|
|
<Check className="h-2.5 w-2.5 mr-0.5" />
|
|
{tActions("saved")}
|
|
</Badge>
|
|
) : (
|
|
(isDirty || offersDirty) && (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
|
|
>
|
|
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
|
|
{t("unsaved")}
|
|
</Badge>
|
|
)
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
#{page.id} · Parent:{" "}
|
|
{page.parentId === -1 ? "Root" : `#${page.parentId}`}
|
|
{page.itemCount !== undefined && ` · ${page.itemCount} items`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dirty banner */}
|
|
{isDirty && (
|
|
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--admin-warning-subtle)] border-b border-[var(--admin-warning-border)] text-xs text-[var(--admin-warning)] shrink-0">
|
|
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1">{t("saveHint")}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 text-[11px] px-2"
|
|
onClick={handleSave}
|
|
disabled={saving || !canEdit}
|
|
>
|
|
{saving ? (
|
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Save className="mr-1 h-3 w-3" />
|
|
)}
|
|
Save
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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"
|
|
>
|
|
<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"
|
|
>
|
|
<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)}
|
|
>
|
|
<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 ||
|
|
form.pageTextTeaser) && (
|
|
<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">Text Teaser</Label>
|
|
<Textarea
|
|
rows={2}
|
|
value={String(form.pageTextTeaser || "")}
|
|
onChange={(e) =>
|
|
update("pageTextTeaser", 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>
|
|
)}
|
|
|
|
{/* 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" ? (
|
|
<BcItemsEditor pageId={page.id} canEdit={canEdit} />
|
|
) : 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}
|
|
catalog={catalogType}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// ── Toggle row (inline) ─────────────────────────────────────
|
|
|
|
function ToggleRow({
|
|
id,
|
|
label,
|
|
description,
|
|
disabled,
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
disabled: boolean;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
const t = useTranslations("pages.admin.catalog.workspace.access");
|
|
return (
|
|
<div className={styles.option} data-disabled={disabled || undefined}>
|
|
<div className={styles.optionHeader}>
|
|
<Label htmlFor={id} className={styles.label}>
|
|
{label}
|
|
</Label>
|
|
<div className={styles.control}>
|
|
<span className={styles.state} aria-hidden="true">
|
|
{t(checked ? "yes" : "no")}
|
|
</span>
|
|
<Switch
|
|
id={id}
|
|
checked={checked}
|
|
onCheckedChange={onChange}
|
|
disabled={disabled}
|
|
aria-describedby={`${id}-description`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p id={`${id}-description`} className={styles.description}>
|
|
{description}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|