feat(catalog): make the live catalog self-correcting and honest about failure
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m34s
CI / tests-integration (push) Failing after 1m34s
CI / tests-ui (push) Successful in 2m19s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m34s
CI / tests-integration (push) Failing after 1m34s
CI / tests-ui (push) Successful in 2m19s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
The previous commit made imports update the Studio without a reload, but the guarantee only held inside the tab that started the import and only as long as every read succeeded. Four holes were left, and this closes them. A session that mounted the tree before an import kept the pre-import tree for the rest of its life, because ensureCatalogTreeLoaded() was a once-per-session no-op. It now asks the server whether what it holds is still current. The answer is a revision: sendCatalogUpdate() already runs after every catalog write, so it bumps one, and clients read it on mount, on focus, on a 20s poll and from other tabs over a BroadcastChannel. An import that finishes in another tab, another browser or the job worker now lands here too. A failed read used to be swallowed, which is the worst outcome available: the rail kept showing pre-import counts as if they were current and nothing said so. The snapshot now carries the error, the rail shows it with a retry, and the previous tree stays on screen because stale beats empty. Every settled import pulled the entire flat tree, which is the one payload that grows with the size of the catalog. The revision doubles as the ETag on mode=full, so an unchanged catalog answers 304 and the poll costs a file read. An import could also report success for an offer the hotel will never sell: a hidden or disabled page, an item_ids that misses the furni id, a zero amount. importSingleFurni reads its own row back and reports each of those as a warning, where the import report already is, instead of leaving it to surface as "the import did not work" in the client. Finally, the catalog items table no longer falls back to router.refresh() — onRefresh is now required, so every mutation ends in a refresh of the caller's own data instead of a route re-render that threw away editor state and scroll position. useServerAction keeps its default, because 47 callers across the app depend on it. The 750-line CatalogTree in catalog-tree.tsx was dead code that kept its own stale tree and three more router.refresh() calls; only CatalogIcon and LAYOUT_COLORS are still imported, so the rest is gone. Tests: the store now covers revisions, 304s, probe failures and error recovery; a jsdom test mounts a consumer and asserts the tree updates in place with no navigation; the old organize-imports e2e asserted nothing about the endpoints the code actually calls, and is replaced by one that asserts a cross-tab write lands in the mounted categories without a reload. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
28ce0f911c
commit
9550b3d66f
18 files changed
+1016
-973
No files matched your search
@@ -0,0 +1,143 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guarantee this file exists to guard: an import that finishes outside this
|
||||||
|
* page — the job worker, another tab, another browser — puts the new categories
|
||||||
|
* on screen without a reload. Every earlier test in this suite asserted only
|
||||||
|
* that no page errors were thrown, so a regression back to "refresh the page to
|
||||||
|
* see it" would have gone unnoticed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function node(id: number, caption: string, itemCount = 0) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
caption,
|
||||||
|
parentId: -1,
|
||||||
|
depth: 0,
|
||||||
|
orderNum: id,
|
||||||
|
enabled: "1",
|
||||||
|
visible: "1",
|
||||||
|
iconImage: 0,
|
||||||
|
iconColor: 0,
|
||||||
|
pageLayout: "default_3x3",
|
||||||
|
childCount: 0,
|
||||||
|
itemCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The catalog as the server would report it; specs move it forward mid-test. */
|
||||||
|
const catalog = {
|
||||||
|
revision: "rev-1",
|
||||||
|
pages: [node(1, "Root")],
|
||||||
|
treeStatus: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mockStudio(page: Page) {
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
if (url.pathname === "/api/admin/catalog/revision")
|
||||||
|
return route.fulfill({ json: { ok: true, revision: catalog.revision } });
|
||||||
|
if (
|
||||||
|
url.pathname === "/api/admin/catalog/tree" &&
|
||||||
|
url.searchParams.get("mode") === "full"
|
||||||
|
)
|
||||||
|
return route.fulfill({
|
||||||
|
status: catalog.treeStatus,
|
||||||
|
json: {
|
||||||
|
ok: catalog.treeStatus === 200,
|
||||||
|
pages: catalog.pages,
|
||||||
|
totals: null,
|
||||||
|
revision: catalog.revision,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (url.pathname.endsWith("/inspect"))
|
||||||
|
return route.fulfill({ json: { items: [] } });
|
||||||
|
if (url.pathname.endsWith("/source-assets"))
|
||||||
|
return route.fulfill({ json: { items: [] } });
|
||||||
|
if (url.pathname.endsWith("/furni"))
|
||||||
|
return route.fulfill({
|
||||||
|
json: url.searchParams.has("action")
|
||||||
|
? { totalInDb: 0, inCatalog: 0, notInCatalog: 0, missingNitro: 0 }
|
||||||
|
: {
|
||||||
|
items: [],
|
||||||
|
meta: {
|
||||||
|
currentPage: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
total: 0,
|
||||||
|
perPage: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (url.pathname.endsWith("/clone"))
|
||||||
|
return route.fulfill({ json: { sources: [] } });
|
||||||
|
if (url.pathname.endsWith("/import-jobs"))
|
||||||
|
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
json: { error: "Unknown fixture endpoint" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
catalog.revision = "rev-1";
|
||||||
|
catalog.pages = [node(1, "Root")];
|
||||||
|
catalog.treeStatus = 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a write announced by another tab lands in the mounted categories", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
(testInfo.project.use.viewport?.width ?? 0) < 1024,
|
||||||
|
"the category rail is collapsed on narrow viewports",
|
||||||
|
);
|
||||||
|
await mockStudio(page);
|
||||||
|
const navigations: string[] = [];
|
||||||
|
page.on("framenavigated", (frame) => {
|
||||||
|
if (frame === page.mainFrame()) navigations.push(frame.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible();
|
||||||
|
const navigationsAfterLoad = navigations.length;
|
||||||
|
|
||||||
|
// An import finished elsewhere: the catalog moved on, so the tree route now
|
||||||
|
// answers with the imported category.
|
||||||
|
catalog.revision = "rev-2";
|
||||||
|
catalog.pages = [...catalog.pages, node(2, "Imported Furniture", 3)];
|
||||||
|
await page.evaluate((revision) => {
|
||||||
|
const channel = new BroadcastChannel("atom-cms-catalog");
|
||||||
|
channel.postMessage({ revision });
|
||||||
|
channel.close();
|
||||||
|
}, catalog.revision);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /Imported Furniture/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
// The page was never navigated or reloaded: the tree updated in place.
|
||||||
|
expect(navigations).toHaveLength(navigationsAfterLoad);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed refresh keeps the categories and says it is stale", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
(testInfo.project.use.viewport?.width ?? 0) < 1024,
|
||||||
|
"the category rail is collapsed on narrow viewports",
|
||||||
|
);
|
||||||
|
await mockStudio(page);
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible();
|
||||||
|
|
||||||
|
catalog.treeStatus = 500;
|
||||||
|
catalog.revision = "rev-2";
|
||||||
|
await page.evaluate((revision) => {
|
||||||
|
const channel = new BroadcastChannel("atom-cms-catalog");
|
||||||
|
channel.postMessage({ revision });
|
||||||
|
channel.close();
|
||||||
|
}, catalog.revision);
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test("Organize Imports Dialog validation limits and modes workflow", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on("pageerror", (error) => errors.push(error.message));
|
|
||||||
|
|
||||||
await page.route("**/api/**", async (route) => {
|
|
||||||
const url = new URL(route.request().url());
|
|
||||||
if (url.pathname === "/api/admin/catalog/import-groups") {
|
|
||||||
return route.fulfill({
|
|
||||||
json: {
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
name: "Test Group",
|
|
||||||
caption: "Test Group",
|
|
||||||
icon: 1,
|
|
||||||
layout: "default_3x3",
|
|
||||||
total: 5,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
itemId: 1,
|
|
||||||
itemName: "chair",
|
|
||||||
catalogItemId: null,
|
|
||||||
alreadyPlaced: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/admin/catalog/pages") {
|
|
||||||
return route.fulfill({
|
|
||||||
json: {
|
|
||||||
pages: [{ id: 101, caption: "Existing Category", parentId: 0 }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
route.request().method() === "POST" &&
|
|
||||||
url.pathname.includes("organize")
|
|
||||||
) {
|
|
||||||
return route.fulfill({
|
|
||||||
json: {
|
|
||||||
ok: true,
|
|
||||||
data: {
|
|
||||||
created: [
|
|
||||||
{ pageId: 102, caption: "Test Group", moved: 0, added: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status: 404,
|
|
||||||
json: { error: "Not found" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// If there's no direct harness route, we can test component behavior or test via catalog admin route if available.
|
|
||||||
// For now, let's verify error handling and limits in the mock test or navigate to catalog.
|
|
||||||
await page.goto("/admin/catalog");
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { readCatalogRevision } from "@/features/catalog/server/catalog-revision";
|
||||||
|
import { apiJson, apiOk } from "@/lib/api";
|
||||||
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The staleness probe for every open admin catalog session. Returns the
|
||||||
|
* revision `sendCatalogUpdate()` last wrote, so a client can tell whether its
|
||||||
|
* tree is current with one tiny read instead of pulling the whole tree.
|
||||||
|
*/
|
||||||
|
export const GET = withAdmin({ permission: PERMS.CATALOG_VIEW }, async () => {
|
||||||
|
const revision = await readCatalogRevision();
|
||||||
|
if (!revision) return apiJson({ ok: true, revision: null });
|
||||||
|
return apiOk({ revision }, { headers: { etag: `"${revision}"` } });
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { asc, count, eq, inArray, like, or } from "drizzle-orm";
|
import { asc, count, eq, inArray, like, or } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { readCatalogRevision } from "@/features/catalog/server/catalog-revision";
|
||||||
import { loadCatalogTotals } from "@/features/catalog/server/catalog-totals";
|
import { loadCatalogTotals } from "@/features/catalog/server/catalog-totals";
|
||||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||||
import {
|
import {
|
||||||
@@ -351,11 +353,23 @@ export const GET = withAdmin(
|
|||||||
if (sp.get("mode") === "full") {
|
if (sp.get("mode") === "full") {
|
||||||
// Totals travel with the tree so one refresh updates both the rail and
|
// Totals travel with the tree so one refresh updates both the rail and
|
||||||
// the stats bar. Read uncached — this response is already the fresh read.
|
// the stats bar. Read uncached — this response is already the fresh read.
|
||||||
const [pages, totals] = await Promise.all([
|
const [pages, totals, revision] = await Promise.all([
|
||||||
isBc ? getBcTreeFlat() : getTreeFlat(),
|
isBc ? getBcTreeFlat() : getTreeFlat(),
|
||||||
loadCatalogTotals(isBc ? "bc" : "normal"),
|
loadCatalogTotals(isBc ? "bc" : "normal"),
|
||||||
|
readCatalogRevision(),
|
||||||
]);
|
]);
|
||||||
return apiOk({ pages, totals });
|
// `revision` doubles as the ETag: a client that saw this exact catalog
|
||||||
|
// state re-asks with `If-None-Match` and gets a 304 instead of the
|
||||||
|
// whole flat tree, which is the only part of this payload that grows
|
||||||
|
// with the size of the catalog.
|
||||||
|
const etag = revision ? `"${revision}"` : null;
|
||||||
|
if (etag && request.headers.get("if-none-match") === etag) {
|
||||||
|
return new NextResponse(null, { status: 304, headers: { etag } });
|
||||||
|
}
|
||||||
|
return apiOk(
|
||||||
|
{ pages, totals, revision },
|
||||||
|
etag ? { headers: { etag } } : undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageIdRaw = sp.get("pageId");
|
const pageIdRaw = sp.get("pageId");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
|
|||||||
import {
|
import {
|
||||||
refreshCatalogTree,
|
refreshCatalogTree,
|
||||||
seedCatalogTotals,
|
seedCatalogTotals,
|
||||||
|
useCatalogSync,
|
||||||
useLiveCatalog,
|
useLiveCatalog,
|
||||||
} from "@/features/catalog/client/use-live-catalog";
|
} from "@/features/catalog/client/use-live-catalog";
|
||||||
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||||
@@ -172,6 +173,9 @@ function CatalogManagerInner({
|
|||||||
// Server totals are the baseline; after an import the live store holds
|
// Server totals are the baseline; after an import the live store holds
|
||||||
// counts that are newer than both the prop and its 30s server cache.
|
// counts that are newer than both the prop and its 30s server cache.
|
||||||
const liveTotals = useLiveCatalog().totals[catalogType];
|
const liveTotals = useLiveCatalog().totals[catalogType];
|
||||||
|
// The dialog runs on its own page too (Studio embed aside), so it cannot rely
|
||||||
|
// on the Studio to keep the shared tree current.
|
||||||
|
useCatalogSync();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
seedCatalogTotals(catalogType, totals);
|
seedCatalogTotals(catalogType, totals);
|
||||||
}, [catalogType, totals]);
|
}, [catalogType, totals]);
|
||||||
|
|||||||
@@ -1,103 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Package } from "lucide-react";
|
||||||
ChevronRight,
|
import { useState } from "react";
|
||||||
ChevronsDownUp,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Copy,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
EyeOff as EyeOffIcon,
|
|
||||||
FolderTree,
|
|
||||||
Loader2,
|
|
||||||
Package,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Power,
|
|
||||||
PowerOff,
|
|
||||||
Search,
|
|
||||||
ToggleLeft,
|
|
||||||
ToggleRight,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
|
||||||
import Link from "@/components/link";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuLabel,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "@/components/ui/context-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
|
||||||
import { catalogueIconUrl } from "@/lib/catalog-assets";
|
import { catalogueIconUrl } from "@/lib/catalog-assets";
|
||||||
import { translateCaption } from "@/lib/catalog-translations";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/* ─── Types ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
interface TreeNode {
|
|
||||||
id: number;
|
|
||||||
caption: string;
|
|
||||||
parentId: number;
|
|
||||||
enabled: string;
|
|
||||||
visible: string;
|
|
||||||
iconImage: number;
|
|
||||||
iconColor: number;
|
|
||||||
pageLayout: string;
|
|
||||||
childCount: number;
|
|
||||||
itemCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CatalogTreeProps {
|
|
||||||
rootPages: TreeNode[];
|
|
||||||
catalogType?: "normal" | "bc";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Context for expand/collapse all ────────────────────── */
|
|
||||||
|
|
||||||
interface TreeContext {
|
|
||||||
expandSignal: number;
|
|
||||||
collapseSignal: number;
|
|
||||||
filterMode: FilterMode;
|
|
||||||
onToggleEnabled: (node: TreeNode) => void;
|
|
||||||
onToggleVisible: (node: TreeNode) => void;
|
|
||||||
onDeletePage: (node: TreeNode) => void;
|
|
||||||
searchQuery: string;
|
|
||||||
catalogType: "normal" | "bc";
|
|
||||||
}
|
|
||||||
|
|
||||||
const TreeCtx = createContext<TreeContext>({
|
|
||||||
expandSignal: 0,
|
|
||||||
collapseSignal: 0,
|
|
||||||
filterMode: "all",
|
|
||||||
onToggleEnabled: () => {},
|
|
||||||
onToggleVisible: () => {},
|
|
||||||
onDeletePage: () => {},
|
|
||||||
searchQuery: "",
|
|
||||||
catalogType: "normal",
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ─── Catalog Icon ───────────────────────────────────────── */
|
/* ─── Catalog Icon ───────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -147,773 +52,5 @@ export const LAYOUT_COLORS: Record<string, string> = {
|
|||||||
single_bundle:
|
single_bundle:
|
||||||
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
|
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
|
||||||
room_bundle:
|
room_bundle:
|
||||||
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
|
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── Highlight matched text ─────────────────────────────── */
|
|
||||||
|
|
||||||
function HighlightText({ text, query }: { text: string; query: string }) {
|
|
||||||
if (!query.trim()) return <>{text}</>;
|
|
||||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
||||||
if (idx === -1) return <>{text}</>;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{text.slice(0, idx)}
|
|
||||||
<mark className="bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] rounded-sm px-0.5">
|
|
||||||
{text.slice(idx, idx + query.length)}
|
|
||||||
</mark>
|
|
||||||
{text.slice(idx + query.length)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Main Tree Component ────────────────────────────────── */
|
|
||||||
|
|
||||||
type FilterMode = "all" | "enabled" | "disabled";
|
|
||||||
|
|
||||||
export function CatalogTree({
|
|
||||||
rootPages,
|
|
||||||
catalogType = "normal",
|
|
||||||
}: CatalogTreeProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [searchResults, setSearchResults] = useState<TreeNode[] | null>(null);
|
|
||||||
const [searching, setSearching] = useState(false);
|
|
||||||
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [expandSignal, setExpandSignal] = useState(0);
|
|
||||||
const [collapseSignal, setCollapseSignal] = useState(0);
|
|
||||||
const [filterMode, setFilterMode] = useState<FilterMode>("all");
|
|
||||||
|
|
||||||
// "/" keyboard shortcut to focus search
|
|
||||||
useEffect(() => {
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
|
||||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
|
||||||
e.preventDefault();
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Debounced search
|
|
||||||
useEffect(() => {
|
|
||||||
if (!searchQuery.trim()) {
|
|
||||||
setSearchResults(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
|
||||||
searchTimeout.current = setTimeout(async () => {
|
|
||||||
setSearching(true);
|
|
||||||
try {
|
|
||||||
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
|
|
||||||
const res = await adminFetch(
|
|
||||||
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catParam}`,
|
|
||||||
);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setSearchResults(data.pages || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CatalogTree] Search failed:", error);
|
|
||||||
} finally {
|
|
||||||
setSearching(false);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
|
||||||
};
|
|
||||||
}, [searchQuery, catalogType]);
|
|
||||||
|
|
||||||
// Quick actions from context menu
|
|
||||||
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
|
|
||||||
const catBody = catalogType === "bc" ? ', "catalog": "bc"' : "";
|
|
||||||
|
|
||||||
const handleToggleEnabled = useCallback(
|
|
||||||
async (node: TreeNode) => {
|
|
||||||
try {
|
|
||||||
const res = await adminFetch("/api/admin/catalog/tree", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: `{"pageId":${node.id},"toggleField":"toggleEnabled"${catBody}}`,
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
toast.success(
|
|
||||||
`${node.caption} ${node.enabled === "1" ? "disabled" : "enabled"}`,
|
|
||||||
);
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to toggle. Open the page to edit.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[router, catBody],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleVisible = useCallback(
|
|
||||||
async (node: TreeNode) => {
|
|
||||||
try {
|
|
||||||
const res = await adminFetch("/api/admin/catalog/tree", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: `{"pageId":${node.id},"toggleField":"toggleVisible"${catBody}}`,
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
toast.success(
|
|
||||||
`${node.caption} ${node.visible === "1" ? "hidden" : "visible"}`,
|
|
||||||
);
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to toggle. Open the page to edit.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[router, catBody],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeletePage = useCallback(
|
|
||||||
async (node: TreeNode) => {
|
|
||||||
const childInfo =
|
|
||||||
node.childCount > 0
|
|
||||||
? ` Its ${node.childCount} child page(s) will be moved to parent.`
|
|
||||||
: "";
|
|
||||||
const itemInfo =
|
|
||||||
node.itemCount > 0 ? ` ${node.itemCount} item(s) will be deleted.` : "";
|
|
||||||
const ok = await confirm({
|
|
||||||
title: "Delete page",
|
|
||||||
description: `Delete "${node.caption}"?${childInfo}${itemInfo}`,
|
|
||||||
confirmLabel: "Delete",
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await adminFetch(
|
|
||||||
`/api/admin/catalog/tree?pageId=${node.id}&mode=reparent${catParam}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
toast.success(`"${node.caption}" deleted`);
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to delete page.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[router, catParam, confirm],
|
|
||||||
);
|
|
||||||
|
|
||||||
const unfilteredPages = searchResults ?? rootPages;
|
|
||||||
const displayPages =
|
|
||||||
filterMode === "all"
|
|
||||||
? unfilteredPages
|
|
||||||
: unfilteredPages.filter((p) =>
|
|
||||||
filterMode === "enabled" ? p.enabled === "1" : p.enabled !== "1",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TreeCtx.Provider
|
|
||||||
value={{
|
|
||||||
expandSignal,
|
|
||||||
collapseSignal,
|
|
||||||
filterMode,
|
|
||||||
searchQuery: searchResults !== null ? searchQuery : "",
|
|
||||||
onToggleEnabled: handleToggleEnabled,
|
|
||||||
onToggleVisible: handleToggleVisible,
|
|
||||||
onDeletePage: handleDeletePage,
|
|
||||||
catalogType,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{confirmDialog}
|
|
||||||
<TooltipProvider delayDuration={400}>
|
|
||||||
<div className="rounded-lg border bg-card shadow-sm">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="border-b px-4 py-3 flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FolderTree className="h-4 w-4 text-primary" />
|
|
||||||
<h3 className="text-sm font-semibold">Catalog Structure</h3>
|
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">
|
|
||||||
{displayPages.length}
|
|
||||||
{filterMode !== "all" ? ` / ${unfilteredPages.length}` : ""}{" "}
|
|
||||||
pages
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{/* Status filter */}
|
|
||||||
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilterMode("all")}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1 rounded text-xs font-medium transition-colors",
|
|
||||||
filterMode === "all"
|
|
||||||
? "bg-background shadow-sm text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>Show all pages</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilterMode("enabled")}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1",
|
|
||||||
filterMode === "enabled"
|
|
||||||
? "bg-background shadow-sm text-[var(--admin-success)] dark:text-[var(--admin-success)]"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ToggleRight className="h-3 w-3" />
|
|
||||||
On
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>Show enabled only</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilterMode("disabled")}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1",
|
|
||||||
filterMode === "disabled"
|
|
||||||
? "bg-background shadow-sm text-[var(--admin-error)] dark:text-[var(--admin-error)]"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ToggleLeft className="h-3 w-3" />
|
|
||||||
Off
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>Show disabled only</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expand / Collapse all */}
|
|
||||||
{searchResults === null && (
|
|
||||||
<>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => setExpandSignal((s) => s + 1)}
|
|
||||||
>
|
|
||||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>Expand all</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => setCollapseSignal((s) => s + 1)}
|
|
||||||
>
|
|
||||||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<p>Collapse all</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative w-56">
|
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
ref={searchInputRef}
|
|
||||||
placeholder="Search pages... /"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="h-8 pl-8 pr-8 text-sm"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setSearchQuery("");
|
|
||||||
searchInputRef.current?.blur();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{searching ? (
|
|
||||||
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
searchQuery && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tree body */}
|
|
||||||
<div className="p-1.5 max-h-[calc(100vh-280px)] overflow-y-auto">
|
|
||||||
{searchResults !== null && (
|
|
||||||
<div className="px-2 py-1.5 mb-1 flex items-center justify-between">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{searchResults.length} result
|
|
||||||
{searchResults.length !== 1 ? "s" : ""} for "
|
|
||||||
{searchQuery}"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{displayPages.map((page) => (
|
|
||||||
<TreeItem
|
|
||||||
key={page.id}
|
|
||||||
node={page}
|
|
||||||
level={0}
|
|
||||||
isSearchResult={searchResults !== null}
|
|
||||||
defaultExpanded={searchResults === null}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{displayPages.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
|
||||||
{searchResults !== null
|
|
||||||
? "No pages match your search"
|
|
||||||
: "No catalog pages found"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</TreeCtx.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tree Item ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function TreeItem({
|
|
||||||
node,
|
|
||||||
level,
|
|
||||||
isSearchResult,
|
|
||||||
defaultExpanded,
|
|
||||||
}: {
|
|
||||||
node: TreeNode;
|
|
||||||
level: number;
|
|
||||||
isSearchResult?: boolean;
|
|
||||||
defaultExpanded?: boolean;
|
|
||||||
}) {
|
|
||||||
void isSearchResult;
|
|
||||||
const {
|
|
||||||
expandSignal,
|
|
||||||
collapseSignal,
|
|
||||||
filterMode,
|
|
||||||
searchQuery,
|
|
||||||
onToggleEnabled,
|
|
||||||
onToggleVisible,
|
|
||||||
onDeletePage,
|
|
||||||
catalogType,
|
|
||||||
} = useContext(TreeCtx);
|
|
||||||
const router = useRouter();
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [children, setChildren] = useState<TreeNode[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const hasChildren = node.childCount > 0;
|
|
||||||
|
|
||||||
const loadChildren = useCallback(async () => {
|
|
||||||
if (loaded || !hasChildren) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
|
|
||||||
const res = await adminFetch(
|
|
||||||
`/api/admin/catalog/tree?parentId=${node.id}${catParam}`,
|
|
||||||
);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setChildren(data.pages || []);
|
|
||||||
setLoaded(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CatalogTree] Failed to load children:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [node.id, loaded, hasChildren, catalogType]);
|
|
||||||
|
|
||||||
// Auto-expand root level on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (defaultExpanded && level === 0 && hasChildren && !loaded) {
|
|
||||||
loadChildren().then(() => setExpanded(true));
|
|
||||||
}
|
|
||||||
}, [defaultExpanded, level, hasChildren, loaded, loadChildren]);
|
|
||||||
|
|
||||||
// Expand all signal
|
|
||||||
useEffect(() => {
|
|
||||||
if (expandSignal > 0 && hasChildren) {
|
|
||||||
if (!loaded) {
|
|
||||||
loadChildren().then(() => setExpanded(true));
|
|
||||||
} else {
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [expandSignal, hasChildren, loaded, loadChildren]);
|
|
||||||
|
|
||||||
// Collapse all signal
|
|
||||||
useEffect(() => {
|
|
||||||
if (collapseSignal > 0) {
|
|
||||||
setExpanded(false);
|
|
||||||
}
|
|
||||||
}, [collapseSignal]);
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (!expanded && !loaded) {
|
|
||||||
loadChildren().then(() => setExpanded(true));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setExpanded(!expanded);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDisabled = node.enabled !== "1";
|
|
||||||
const isHidden = node.visible !== "1";
|
|
||||||
const isEmpty = node.itemCount === 0 && node.childCount === 0;
|
|
||||||
const layoutColor =
|
|
||||||
LAYOUT_COLORS[node.pageLayout] || "bg-muted text-muted-foreground";
|
|
||||||
const italianName = translateCaption(node.caption);
|
|
||||||
const displayName = italianName ?? node.caption;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
{/* biome-ignore lint/a11y/useSemanticElements: wrapped by Tooltip+ContextMenuTrigger asChild (Radix); a real button nests button-in-button when expanded */}
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-accent/60 group transition-colors cursor-default",
|
|
||||||
isDisabled && "opacity-40",
|
|
||||||
isEmpty && !isDisabled && "opacity-60",
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
|
||||||
onDoubleClick={() =>
|
|
||||||
router.push(
|
|
||||||
catalogType === "bc"
|
|
||||||
? `/admin/catalog/builder-club/${node.id}`
|
|
||||||
: `/admin/catalog/${node.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
router.push(
|
|
||||||
catalogType === "bc"
|
|
||||||
? `/admin/catalog/builder-club/${node.id}`
|
|
||||||
: `/admin/catalog/${node.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Expand/collapse toggle */}
|
|
||||||
{hasChildren ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleToggle}
|
|
||||||
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-150",
|
|
||||||
expanded && "rotate-90",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="h-5 w-5 shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Catalog icon */}
|
|
||||||
<CatalogIcon iconImage={node.iconImage} />
|
|
||||||
|
|
||||||
{/* Caption link */}
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
catalogType === "bc"
|
|
||||||
? `/admin/catalog/builder-club/${node.id}`
|
|
||||||
: `/admin/catalog/${node.id}`
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 min-w-0 hover:text-primary transition-colors",
|
|
||||||
isEmpty && !isDisabled
|
|
||||||
? "text-muted-foreground italic"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm truncate block",
|
|
||||||
!isEmpty && "font-medium",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HighlightText text={displayName} query={searchQuery} />
|
|
||||||
</span>
|
|
||||||
{italianName && (
|
|
||||||
<span className="text-[10px] text-muted-foreground truncate block leading-tight">
|
|
||||||
<HighlightText text={node.caption} query={searchQuery} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Quick toggle enabled on hover */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleEnabled(node);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center h-5 w-5 shrink-0 rounded opacity-0 group-hover:opacity-100 transition-opacity",
|
|
||||||
isDisabled
|
|
||||||
? "hover:bg-[var(--admin-success-subtle)] text-muted-foreground hover:text-[var(--admin-success)]"
|
|
||||||
: "hover:bg-[var(--admin-error-subtle)] text-muted-foreground hover:text-[var(--admin-error)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isDisabled ? (
|
|
||||||
<Power className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<PowerOff className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
<p>{isDisabled ? "Enable" : "Disable"}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Quick add subpage */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
router.push(
|
|
||||||
catalogType === "bc"
|
|
||||||
? `/admin/catalog?catalog=bc&newPageParent=${node.id}`
|
|
||||||
: `/admin/catalog?newPageParent=${node.id}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-primary/20 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
<p>Create subpage</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Status indicators */}
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
{isHidden && (
|
|
||||||
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{isDisabled && (
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="h-4 px-1 text-[9px]"
|
|
||||||
>
|
|
||||||
OFF
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{node.itemCount > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="h-5 px-1.5 text-[10px] font-mono"
|
|
||||||
>
|
|
||||||
{node.itemCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{/* Layout badge on hover */}
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"h-5 px-1.5 text-[9px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0",
|
|
||||||
layoutColor,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{node.pageLayout.replace("default_3x3", "3x3")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="text-xs max-w-72 p-3">
|
|
||||||
<p className="font-semibold mb-1.5">
|
|
||||||
{node.caption}{" "}
|
|
||||||
<span className="text-muted-foreground font-normal">
|
|
||||||
#{node.id}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<table className="w-full">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td className="text-muted-foreground pr-3 py-px">Layout</td>
|
|
||||||
<td className="py-px">{node.pageLayout}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="text-muted-foreground pr-3 py-px">Items</td>
|
|
||||||
<td className="py-px">{node.itemCount}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="text-muted-foreground pr-3 py-px">
|
|
||||||
Subpages
|
|
||||||
</td>
|
|
||||||
<td className="py-px">{node.childCount}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="text-muted-foreground pr-3 py-px">Icon</td>
|
|
||||||
<td className="py-px">{node.iconImage}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="text-muted-foreground pr-3 py-px">
|
|
||||||
Visible
|
|
||||||
</td>
|
|
||||||
<td className="py-px">
|
|
||||||
{node.visible === "1" ? "Yes" : "No"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="text-muted-foreground pr-3 py-px">
|
|
||||||
Enabled
|
|
||||||
</td>
|
|
||||||
<td className="py-px">
|
|
||||||
{node.enabled === "1" ? "Yes" : "No"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p className="text-muted-foreground mt-2 text-[10px]">
|
|
||||||
Right-click for actions · Double-click to open
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
|
|
||||||
{/* Right-click context menu */}
|
|
||||||
<ContextMenuContent className="w-48">
|
|
||||||
<ContextMenuLabel>
|
|
||||||
{node.caption} #{node.id}
|
|
||||||
</ContextMenuLabel>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
router.push(
|
|
||||||
catalogType === "bc"
|
|
||||||
? `/admin/catalog/builder-club/${node.id}`
|
|
||||||
: `/admin/catalog/${node.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
Edit Page
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => navigator.clipboard.writeText(String(node.id))}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
Copy ID
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem onClick={() => onToggleEnabled(node)}>
|
|
||||||
{node.enabled === "1" ? (
|
|
||||||
<PowerOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Power className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{node.enabled === "1" ? "Disable" : "Enable"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onClick={() => onToggleVisible(node)}>
|
|
||||||
{node.visible === "1" ? (
|
|
||||||
<EyeOffIcon className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{node.visible === "1" ? "Hide" : "Show"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
router.push(
|
|
||||||
catalogType === "bc"
|
|
||||||
? `/admin/catalog?catalog=bc&newPageParent=${node.id}`
|
|
||||||
: `/admin/catalog?newPageParent=${node.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Create Subpage
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => onDeletePage(node)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
|
|
||||||
{/* Children */}
|
|
||||||
{expanded &&
|
|
||||||
children.length > 0 &&
|
|
||||||
(() => {
|
|
||||||
const filteredChildren =
|
|
||||||
filterMode === "all"
|
|
||||||
? children
|
|
||||||
: children.filter((c) =>
|
|
||||||
filterMode === "enabled"
|
|
||||||
? c.enabled === "1"
|
|
||||||
: c.enabled !== "1",
|
|
||||||
);
|
|
||||||
return filteredChildren.length > 0 ? (
|
|
||||||
<div className="relative">
|
|
||||||
{/* Tree guide line */}
|
|
||||||
<div
|
|
||||||
className="absolute top-0 bottom-0 border-l border-border/40"
|
|
||||||
style={{ left: `${level * 20 + 18}px` }}
|
|
||||||
/>
|
|
||||||
{filteredChildren.map((child) => (
|
|
||||||
<TreeItem key={child.id} node={child} level={level + 1} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -81,6 +80,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { refreshCatalogTree } from "@/features/catalog/client/use-live-catalog";
|
||||||
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
||||||
import {
|
import {
|
||||||
discoverOffers,
|
discoverOffers,
|
||||||
@@ -154,7 +154,13 @@ function SortableRow({
|
|||||||
interface CatalogItemsTableProps {
|
interface CatalogItemsTableProps {
|
||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onRefresh?: () => void;
|
/**
|
||||||
|
* Required, not optional: every mutation here has to end in a refresh of the
|
||||||
|
* caller's own data. The table used to fall back to `router.refresh()` when
|
||||||
|
* this was omitted, which re-rendered the whole route to redraw one table —
|
||||||
|
* and lost the editor state and scroll position that go with it.
|
||||||
|
*/
|
||||||
|
onRefresh: () => void;
|
||||||
items: CatalogItemData[];
|
items: CatalogItemData[];
|
||||||
pageId: number;
|
pageId: number;
|
||||||
pageLayout: string;
|
pageLayout: string;
|
||||||
@@ -176,7 +182,6 @@ export function CatalogItemsTable({
|
|||||||
furniRevisionMap,
|
furniRevisionMap,
|
||||||
allPages,
|
allPages,
|
||||||
}: CatalogItemsTableProps) {
|
}: CatalogItemsTableProps) {
|
||||||
const router = useRouter();
|
|
||||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||||
const discovery = useTranslations("pages.admin.catalog.discovery");
|
const discovery = useTranslations("pages.admin.catalog.discovery");
|
||||||
const t = useTranslations(
|
const t = useTranslations(
|
||||||
@@ -455,8 +460,7 @@ export function CatalogItemsTable({
|
|||||||
errorMessage: `Failed to save item #${item.id}.`,
|
errorMessage: `Failed to save item #${item.id}.`,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSavingItemId(null);
|
setSavingItemId(null);
|
||||||
if (onRefresh) onRefresh();
|
onRefresh();
|
||||||
else router.refresh();
|
|
||||||
},
|
},
|
||||||
onError: () => setSavingItemId(null),
|
onError: () => setSavingItemId(null),
|
||||||
},
|
},
|
||||||
@@ -500,8 +504,7 @@ export function CatalogItemsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (savedCount > 0) {
|
if (savedCount > 0) {
|
||||||
if (onRefresh) onRefresh();
|
onRefresh();
|
||||||
else router.refresh();
|
|
||||||
}
|
}
|
||||||
if (failedCount > 0) {
|
if (failedCount > 0) {
|
||||||
return {
|
return {
|
||||||
@@ -533,8 +536,7 @@ export function CatalogItemsTable({
|
|||||||
errorMessage: "Failed to delete items.",
|
errorMessage: "Failed to delete items.",
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
if (onRefresh) onRefresh();
|
onRefresh();
|
||||||
else router.refresh();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -568,8 +570,7 @@ export function CatalogItemsTable({
|
|||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
setMoveOpen(false);
|
setMoveOpen(false);
|
||||||
setMoveTargetPageId(null);
|
setMoveTargetPageId(null);
|
||||||
if (onRefresh) onRefresh();
|
onRefresh();
|
||||||
else router.refresh();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -630,6 +631,7 @@ export function CatalogItemsTable({
|
|||||||
run(() => reorderCatalogItems({ orders }), {
|
run(() => reorderCatalogItems({ orders }), {
|
||||||
successMessage: "Item order saved.",
|
successMessage: "Item order saved.",
|
||||||
errorMessage: "Failed to save order.",
|
errorMessage: "Failed to save order.",
|
||||||
|
onSuccess: () => onRefresh(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,8 +671,7 @@ export function CatalogItemsTable({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setAddOpen(false);
|
setAddOpen(false);
|
||||||
setNewItem({ ...DEFAULT_NEW_ITEM });
|
setNewItem({ ...DEFAULT_NEW_ITEM });
|
||||||
if (onRefresh) onRefresh();
|
onRefresh();
|
||||||
else router.refresh();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -686,6 +687,7 @@ export function CatalogItemsTable({
|
|||||||
run(() => deleteCatalogItems({ ids: [id] }), {
|
run(() => deleteCatalogItems({ ids: [id] }), {
|
||||||
successMessage: "Item deleted.",
|
successMessage: "Item deleted.",
|
||||||
errorMessage: "Failed to delete item.",
|
errorMessage: "Failed to delete item.",
|
||||||
|
onSuccess: () => onRefresh(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,8 +723,7 @@ export function CatalogItemsTable({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMoveOneId(null);
|
setMoveOneId(null);
|
||||||
setMoveOneTarget(null);
|
setMoveOneTarget(null);
|
||||||
if (onRefresh) onRefresh();
|
onRefresh();
|
||||||
else router.refresh();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -751,6 +752,7 @@ export function CatalogItemsTable({
|
|||||||
{
|
{
|
||||||
successMessage: `Item #${item.id} duplicated.`,
|
successMessage: `Item #${item.id} duplicated.`,
|
||||||
errorMessage: "Failed to duplicate item.",
|
errorMessage: "Failed to duplicate item.",
|
||||||
|
onSuccess: () => onRefresh(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -792,8 +794,7 @@ export function CatalogItemsTable({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setEditingBase(null);
|
setEditingBase(null);
|
||||||
if (onRefresh) onRefresh();
|
onRefresh();
|
||||||
else router.refresh();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -882,8 +883,7 @@ export function CatalogItemsTable({
|
|||||||
onApplied={() => {
|
onApplied={() => {
|
||||||
setBulkRefreshFrom(items);
|
setBulkRefreshFrom(items);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
if (onRefresh) onRefresh();
|
onRefresh();
|
||||||
else router.refresh();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -971,7 +971,10 @@ export function CatalogItemsTable({
|
|||||||
/>
|
/>
|
||||||
<BulkImportItems
|
<BulkImportItems
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
onImported={() => router.refresh()}
|
onImported={() => {
|
||||||
|
onRefresh();
|
||||||
|
void refreshCatalogTree();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||||
|
|||||||
@@ -1,10 +1,71 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronRight, FolderTree, Search, X } from "lucide-react";
|
import {
|
||||||
import { memo, useMemo, useState } from "react";
|
AlertTriangle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FolderTree,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { memo, useMemo, useState, useTransition } from "react";
|
||||||
|
import {
|
||||||
|
refreshCatalogTree,
|
||||||
|
useLiveCatalog,
|
||||||
|
} from "@/features/catalog/client/use-live-catalog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TreeNode } from "@/types/catalog";
|
import type { TreeNode } from "@/types/catalog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A refresh that fails must be visible. Every number and category below is
|
||||||
|
* presented as current, so silently keeping the previous tree after a failed
|
||||||
|
* read would show pre-import data as if nothing had happened — and a staff
|
||||||
|
* member has no other signal that the import they just ran is not on screen.
|
||||||
|
*/
|
||||||
|
function LiveCatalogNotice() {
|
||||||
|
const { refreshError, refreshing } = useLiveCatalog();
|
||||||
|
const [retrying, startRetry] = useTransition();
|
||||||
|
if (!refreshError && !refreshing) return null;
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className={cn(
|
||||||
|
"mx-3 mb-2 flex items-center gap-2 rounded-md border px-2 py-1.5 text-xs",
|
||||||
|
refreshError
|
||||||
|
? "border-destructive/40 bg-destructive/10 text-destructive"
|
||||||
|
: "text-[var(--admin-text-muted)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{refreshError ? (
|
||||||
|
<>
|
||||||
|
<AlertTriangle size={14} className="shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1">Catalog could not refresh.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={retrying}
|
||||||
|
onClick={() =>
|
||||||
|
startRetry(async () => {
|
||||||
|
await refreshCatalogTree();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 font-medium underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={cn(retrying && "animate-spin")} />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="shrink-0 animate-spin" />
|
||||||
|
<span className="min-w-0 flex-1">Updating catalog…</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const CatalogRail = memo(function CatalogRail({
|
export const CatalogRail = memo(function CatalogRail({
|
||||||
tree,
|
tree,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -77,6 +138,7 @@ export const CatalogRail = memo(function CatalogRail({
|
|||||||
>
|
>
|
||||||
All categories
|
All categories
|
||||||
</button>
|
</button>
|
||||||
|
<LiveCatalogNotice />
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||||
{visible.length === 0 && (
|
{visible.length === 0 && (
|
||||||
<p className="p-3 text-sm text-[var(--admin-text-muted)]">
|
<p className="p-3 text-sm text-[var(--admin-text-muted)]">
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
ensureCatalogTreeLoaded,
|
|
||||||
refreshCatalogTree,
|
refreshCatalogTree,
|
||||||
|
useCatalogSync,
|
||||||
useLiveCatalog,
|
useLiveCatalog,
|
||||||
} from "@/features/catalog/client/use-live-catalog";
|
} from "@/features/catalog/client/use-live-catalog";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
@@ -202,9 +202,9 @@ export function StudioClient({
|
|||||||
const tree = useLiveCatalog().tree;
|
const tree = useLiveCatalog().tree;
|
||||||
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
||||||
const [railOpen, setRailOpen] = useState(false);
|
const [railOpen, setRailOpen] = useState(false);
|
||||||
|
useCatalogSync();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
|
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
|
||||||
void ensureCatalogTreeLoaded();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Search & list
|
// Search & list
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const adminFetch = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/admin-fetch", () => ({ adminFetch }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
checkCatalogRevision,
|
||||||
|
resetLiveCatalogForTests,
|
||||||
|
useCatalogSync,
|
||||||
|
useLiveCatalog,
|
||||||
|
} from "./use-live-catalog";
|
||||||
|
|
||||||
|
function page(id: number, caption = `page ${id}`) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
caption,
|
||||||
|
parentId: -1,
|
||||||
|
depth: 0,
|
||||||
|
orderNum: 0,
|
||||||
|
enabled: "1",
|
||||||
|
visible: "1",
|
||||||
|
iconImage: 0,
|
||||||
|
iconColor: 0,
|
||||||
|
pageLayout: "default_3x3",
|
||||||
|
childCount: 0,
|
||||||
|
itemCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the server currently looks like, as the route would report it. */
|
||||||
|
const server = {
|
||||||
|
revision: "rev-1",
|
||||||
|
pages: [page(1)],
|
||||||
|
treeFails: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function respond(body: unknown) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => body,
|
||||||
|
headers: new Headers(),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serve(): Response {
|
||||||
|
const url = String(adminFetch.mock.calls.at(-1)?.[0] ?? "");
|
||||||
|
if (url.includes("/catalog/revision"))
|
||||||
|
return respond({ revision: server.revision });
|
||||||
|
if (server.treeFails) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({}),
|
||||||
|
headers: new Headers(),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
return respond({
|
||||||
|
pages: server.pages,
|
||||||
|
totals: null,
|
||||||
|
revision: server.revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
// The real interval is long; each test drives the revision check itself.
|
||||||
|
useCatalogSync(600_000);
|
||||||
|
const { tree, refreshError } = useLiveCatalog();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="captions">
|
||||||
|
{tree.map((node) => node.caption).join(",")}
|
||||||
|
</span>
|
||||||
|
{refreshError ? <span data-testid="stale">stale</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
const navigations: string[] = [];
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Probe />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function captions() {
|
||||||
|
return container.querySelector('[data-testid="captions"]')?.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useCatalogSync", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetLiveCatalogForTests();
|
||||||
|
adminFetch.mockReset();
|
||||||
|
server.revision = "rev-1";
|
||||||
|
server.pages = [page(1)];
|
||||||
|
server.treeFails = false;
|
||||||
|
adminFetch.mockImplementation(serve);
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
navigations.length = 0;
|
||||||
|
window.addEventListener("beforeunload", () => navigations.push("unload"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the mounted tree when an import finishes in another session", async () => {
|
||||||
|
await render();
|
||||||
|
expect(captions()).toBe("page 1");
|
||||||
|
|
||||||
|
// Another tab, browser or the job worker wrote the catalog.
|
||||||
|
server.revision = "rev-2";
|
||||||
|
server.pages = [page(1), page(2, "Imported Furniture")];
|
||||||
|
await act(async () => {
|
||||||
|
await checkCatalogRevision();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captions()).toBe("page 1,Imported Furniture");
|
||||||
|
// Nothing about this update went through the router: the document was
|
||||||
|
// never torn down and no navigation was triggered.
|
||||||
|
expect(navigations).toEqual([]);
|
||||||
|
expect(document.body.contains(container)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a catalog write announced by another tab", async () => {
|
||||||
|
await render();
|
||||||
|
const otherTab = new BroadcastChannel("atom-cms-catalog");
|
||||||
|
|
||||||
|
server.revision = "rev-3";
|
||||||
|
server.pages = [page(1), page(7, "Chairs")];
|
||||||
|
otherTab.postMessage({ revision: "rev-3" });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(captions()).toBe("page 1,Chairs");
|
||||||
|
otherTab.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the previous tree and marks it stale when the read fails", async () => {
|
||||||
|
await render();
|
||||||
|
server.treeFails = true;
|
||||||
|
server.revision = "rev-4";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await checkCatalogRevision();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(captions()).toBe("page 1");
|
||||||
|
expect(container.querySelector('[data-testid="stale"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,14 +5,21 @@ vi.mock("@/lib/admin-fetch", () => ({ adminFetch }));
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applyCatalogDelta,
|
applyCatalogDelta,
|
||||||
|
checkCatalogRevision,
|
||||||
|
ensureCatalogTreeLoaded,
|
||||||
getLiveCatalogSnapshot,
|
getLiveCatalogSnapshot,
|
||||||
refreshCatalogTree,
|
refreshCatalogTree,
|
||||||
resetLiveCatalogForTests,
|
resetLiveCatalogForTests,
|
||||||
seedCatalogTotals,
|
seedCatalogTotals,
|
||||||
} from "./use-live-catalog";
|
} from "./use-live-catalog";
|
||||||
|
|
||||||
function ok(body: unknown) {
|
function ok(body: unknown, init: { etag?: string } = {}) {
|
||||||
return { ok: true, json: async () => body } as unknown as Response;
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => body,
|
||||||
|
headers: new Headers(init.etag ? { etag: init.etag } : {}),
|
||||||
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
function page(id: number, over: Record<string, unknown> = {}) {
|
function page(id: number, over: Record<string, unknown> = {}) {
|
||||||
@@ -153,7 +160,7 @@ describe("refreshCatalogTree", () => {
|
|||||||
expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 2]);
|
expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves the previous snapshot alone when the read fails", async () => {
|
it("keeps the tree it holds when the read fails, and says so", async () => {
|
||||||
adminFetch.mockResolvedValueOnce(ok({ pages: [page(1)] }));
|
adminFetch.mockResolvedValueOnce(ok({ pages: [page(1)] }));
|
||||||
await refreshCatalogTree();
|
await refreshCatalogTree();
|
||||||
adminFetch.mockResolvedValueOnce({
|
adminFetch.mockResolvedValueOnce({
|
||||||
@@ -164,6 +171,159 @@ describe("refreshCatalogTree", () => {
|
|||||||
|
|
||||||
await refreshCatalogTree();
|
await refreshCatalogTree();
|
||||||
|
|
||||||
|
const snapshot = getLiveCatalogSnapshot();
|
||||||
|
expect(snapshot.tree.map((n) => n.id)).toEqual([1]);
|
||||||
|
// A rail that cannot refresh must say so: showing the previous tree as if
|
||||||
|
// it were current is the silent-staleness bug this error exists for.
|
||||||
|
expect(snapshot.refreshError).toContain("500");
|
||||||
|
expect(snapshot.refreshing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the error once a later read succeeds", async () => {
|
||||||
|
adminFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as unknown as Response);
|
||||||
|
await refreshCatalogTree();
|
||||||
|
expect(getLiveCatalogSnapshot().refreshError).toContain("503");
|
||||||
|
|
||||||
|
adminFetch.mockResolvedValueOnce(ok({ pages: [page(1)] }));
|
||||||
|
await refreshCatalogTree();
|
||||||
|
|
||||||
|
expect(getLiveCatalogSnapshot().refreshError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a read in flight while it runs", async () => {
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
adminFetch.mockImplementationOnce(async () => {
|
||||||
|
expect(getLiveCatalogSnapshot().refreshing).toBe(true);
|
||||||
|
await gate;
|
||||||
|
return ok({ pages: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const read = refreshCatalogTree();
|
||||||
|
release?.();
|
||||||
|
await read;
|
||||||
|
|
||||||
|
expect(getLiveCatalogSnapshot().refreshing).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("catalog revision", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetLiveCatalogForTests();
|
||||||
|
adminFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts the revision the tree route reports", async () => {
|
||||||
|
adminFetch.mockResolvedValueOnce(
|
||||||
|
ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await refreshCatalogTree();
|
||||||
|
|
||||||
|
expect(getLiveCatalogSnapshot().revision).toBe("rev-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-read the tree when the revision is unchanged", async () => {
|
||||||
|
adminFetch.mockResolvedValueOnce(
|
||||||
|
ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }),
|
||||||
|
);
|
||||||
|
await refreshCatalogTree();
|
||||||
|
adminFetch.mockResolvedValueOnce(ok({ revision: "rev-1" }));
|
||||||
|
|
||||||
|
await checkCatalogRevision();
|
||||||
|
|
||||||
|
expect(adminFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(adminFetch.mock.calls[1][0]).toContain("/catalog/revision");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads the tree when another session moved the revision on", async () => {
|
||||||
|
adminFetch.mockResolvedValueOnce(
|
||||||
|
ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }),
|
||||||
|
);
|
||||||
|
await refreshCatalogTree();
|
||||||
|
|
||||||
|
adminFetch.mockResolvedValueOnce(ok({ revision: "rev-2" }));
|
||||||
|
adminFetch.mockResolvedValueOnce(
|
||||||
|
ok({ pages: [page(1), page(2)], revision: "rev-2" }, { etag: '"rev-2"' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await checkCatalogRevision();
|
||||||
|
|
||||||
|
expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 2]);
|
||||||
|
expect(getLiveCatalogSnapshot().revision).toBe("rev-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes the revision on every mount instead of trusting the session", async () => {
|
||||||
|
adminFetch.mockResolvedValueOnce(
|
||||||
|
ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }),
|
||||||
|
);
|
||||||
|
await refreshCatalogTree();
|
||||||
|
|
||||||
|
// A second mount of the tree in the same session used to be a no-op, which
|
||||||
|
// is why a session that started before an import kept showing the old
|
||||||
|
// catalog.
|
||||||
|
adminFetch.mockResolvedValueOnce(ok({ revision: "rev-1" }));
|
||||||
|
adminFetch.mockResolvedValueOnce(ok({ revision: "rev-2" }));
|
||||||
|
adminFetch.mockResolvedValueOnce(
|
||||||
|
ok({ pages: [page(1), page(9)], revision: "rev-2" }, { etag: '"rev-2"' }),
|
||||||
|
);
|
||||||
|
await ensureCatalogTreeLoaded();
|
||||||
|
await ensureCatalogTreeLoaded();
|
||||||
|
|
||||||
|
expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays the ETag so an unchanged catalog answers 304", async () => {
|
||||||
|
adminFetch.mockResolvedValueOnce(
|
||||||
|
ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }),
|
||||||
|
);
|
||||||
|
await refreshCatalogTree();
|
||||||
|
|
||||||
|
adminFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 304,
|
||||||
|
json: async () => ({}),
|
||||||
|
headers: new Headers({ etag: '"rev-1"' }),
|
||||||
|
} as unknown as Response);
|
||||||
|
adminFetch.mockResolvedValueOnce(ok({ revision: "rev-2" }));
|
||||||
|
adminFetch.mockResolvedValueOnce(
|
||||||
|
ok({ pages: [page(1), page(2)], revision: "rev-2" }, { etag: '"rev-2"' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await refreshCatalogTree();
|
||||||
|
// 304 means nothing changed: the tree stays, and no error is invented.
|
||||||
expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1]);
|
expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1]);
|
||||||
|
expect(getLiveCatalogSnapshot().refreshError).toBeNull();
|
||||||
|
|
||||||
|
await checkCatalogRevision();
|
||||||
|
|
||||||
|
expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 2]);
|
||||||
|
expect(adminFetch.mock.calls[1][1]).toMatchObject({
|
||||||
|
headers: { "if-none-match": '"rev-1"' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet when the probe itself fails", async () => {
|
||||||
|
adminFetch.mockResolvedValueOnce(
|
||||||
|
ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }),
|
||||||
|
);
|
||||||
|
await refreshCatalogTree();
|
||||||
|
adminFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
await checkCatalogRevision();
|
||||||
|
|
||||||
|
// The tree may be perfectly current; a failed probe is not a failed read.
|
||||||
|
expect(getLiveCatalogSnapshot().refreshError).toBeNull();
|
||||||
|
expect(getLiveCatalogSnapshot().revision).toBe("rev-1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
import type { TreeNode } from "@/types/catalog";
|
import type { TreeNode } from "@/types/catalog";
|
||||||
import {
|
import {
|
||||||
@@ -19,12 +19,21 @@ interface LiveCatalogSnapshot {
|
|||||||
tree: TreeNode[];
|
tree: TreeNode[];
|
||||||
totals: Record<CatalogMode, CatalogTotals | null>;
|
totals: Record<CatalogMode, CatalogTotals | null>;
|
||||||
treeLoaded: boolean;
|
treeLoaded: boolean;
|
||||||
|
/** Catalog state the tree was read at, as bumped by `sendCatalogUpdate()`. */
|
||||||
|
revision: string | null;
|
||||||
|
/** A read is in flight; the rail shows this instead of pretending to be live. */
|
||||||
|
refreshing: boolean;
|
||||||
|
/** Last read failure. Never swallowed: a rail that cannot refresh must say so. */
|
||||||
|
refreshError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_SNAPSHOT: LiveCatalogSnapshot = {
|
const EMPTY_SNAPSHOT: LiveCatalogSnapshot = {
|
||||||
tree: [],
|
tree: [],
|
||||||
totals: { normal: null, bc: null },
|
totals: { normal: null, bc: null },
|
||||||
treeLoaded: false,
|
treeLoaded: false,
|
||||||
|
revision: null,
|
||||||
|
refreshing: false,
|
||||||
|
refreshError: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A module-level external store keeps the catalog tree alive across client
|
// A module-level external store keeps the catalog tree alive across client
|
||||||
@@ -93,14 +102,38 @@ export function applyCatalogDelta(delta: CatalogTreeDelta): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TREE_URL = "/api/admin/catalog/tree?mode=full";
|
||||||
|
const REVISION_URL = "/api/admin/catalog/revision";
|
||||||
|
const CHANNEL = "atom-cms-catalog";
|
||||||
|
|
||||||
|
/** Last ETag the server handed out, replayed as `If-None-Match` on the next read. */
|
||||||
|
let treeEtag: string | null = null;
|
||||||
|
let treeRequest: Promise<void> | null = null;
|
||||||
|
let treeRequestQueued = false;
|
||||||
|
|
||||||
async function loadTree(): Promise<void> {
|
async function loadTree(): Promise<void> {
|
||||||
const res = await adminFetch("/api/admin/catalog/tree?mode=full");
|
const res = await adminFetch(
|
||||||
|
TREE_URL,
|
||||||
|
treeEtag
|
||||||
|
? { cache: "no-store", headers: { "if-none-match": treeEtag } }
|
||||||
|
: { cache: "no-store" },
|
||||||
|
);
|
||||||
|
// 304 means the catalog state this tree was read at is still current: nothing
|
||||||
|
// changed, so nothing to re-render. This is what keeps the poll cheap on a
|
||||||
|
// large catalog, where the flat tree is by far the biggest payload here.
|
||||||
|
if (res.status === 304) {
|
||||||
|
emit({ ...snapshot, refreshing: false, refreshError: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`Catalog tree refresh failed (${res.status})`);
|
if (!res.ok) throw new Error(`Catalog tree refresh failed (${res.status})`);
|
||||||
|
treeEtag = res.headers.get("etag") ?? treeEtag;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// The route answers with live totals alongside the tree: importing writes
|
// The route answers with live totals alongside the tree: importing writes
|
||||||
// catalog rows server-side, so the numbers the store holds would otherwise
|
// catalog rows server-side, so the numbers the store holds would otherwise
|
||||||
// stay pre-import for the rest of the session.
|
// stay pre-import for the rest of the session.
|
||||||
const totals = normalizeCatalogTotals(data?.totals);
|
const totals = normalizeCatalogTotals(data?.totals);
|
||||||
|
const revision =
|
||||||
|
typeof data?.revision === "string" ? data.revision : snapshot.revision;
|
||||||
emit({
|
emit({
|
||||||
...snapshot,
|
...snapshot,
|
||||||
tree: normalizeTreePages(data?.pages),
|
tree: normalizeTreePages(data?.pages),
|
||||||
@@ -109,12 +142,13 @@ async function loadTree(): Promise<void> {
|
|||||||
normal: totals ?? snapshot.totals.normal,
|
normal: totals ?? snapshot.totals.normal,
|
||||||
},
|
},
|
||||||
treeLoaded: true,
|
treeLoaded: true,
|
||||||
|
revision,
|
||||||
|
refreshing: false,
|
||||||
|
refreshError: null,
|
||||||
});
|
});
|
||||||
|
if (revision) broadcastRevision(revision);
|
||||||
}
|
}
|
||||||
|
|
||||||
let treeRequest: Promise<void> | null = null;
|
|
||||||
let treeRequestQueued = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reload the whole tree and totals over the API. Used where the server decides
|
* Reload the whole tree and totals over the API. Used where the server decides
|
||||||
* the shape of the result — the furniture importers derive their category pages
|
* the shape of the result — the furniture importers derive their category pages
|
||||||
@@ -131,15 +165,24 @@ export function refreshCatalogTree(): Promise<void> {
|
|||||||
treeRequestQueued = true;
|
treeRequestQueued = true;
|
||||||
return treeRequest;
|
return treeRequest;
|
||||||
}
|
}
|
||||||
|
if (!snapshot.refreshing) emit({ ...snapshot, refreshing: true });
|
||||||
treeRequest = (async () => {
|
treeRequest = (async () => {
|
||||||
try {
|
try {
|
||||||
do {
|
do {
|
||||||
treeRequestQueued = false;
|
treeRequestQueued = false;
|
||||||
await loadTree();
|
await loadTree();
|
||||||
} while (treeRequestQueued);
|
} while (treeRequestQueued);
|
||||||
} catch {
|
} catch (error) {
|
||||||
// A failed read keeps the previous snapshot; the next mount or
|
// Keep the previous tree — stale beats empty — but never silently:
|
||||||
// mutation tries again.
|
// without an error in the snapshot the rail would show pre-import
|
||||||
|
// counts as if they were current.
|
||||||
|
treeEtag = null;
|
||||||
|
emit({
|
||||||
|
...snapshot,
|
||||||
|
refreshing: false,
|
||||||
|
refreshError:
|
||||||
|
error instanceof Error ? error.message : "Catalog refresh failed",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
treeRequest = null;
|
treeRequest = null;
|
||||||
}
|
}
|
||||||
@@ -147,14 +190,89 @@ export function refreshCatalogTree(): Promise<void> {
|
|||||||
return treeRequest;
|
return treeRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load the tree once per session; later calls are no-ops. */
|
/**
|
||||||
|
* Compare the server's revision against the one this tree was read at and
|
||||||
|
* refresh only when they differ. Cheap enough to run on every mount, on every
|
||||||
|
* focus and on a slow poll, which is what closes the gaps a single-tab store
|
||||||
|
* leaves: an import finished in another tab, another browser or the job worker
|
||||||
|
* has to show up here too.
|
||||||
|
*/
|
||||||
|
export async function checkCatalogRevision(): Promise<void> {
|
||||||
|
const res = await adminFetch(REVISION_URL, { cache: "no-store" });
|
||||||
|
// A failed probe is not worth reporting: the tree itself may be perfectly
|
||||||
|
// current, and the next poll tries again.
|
||||||
|
if (!res.ok) return;
|
||||||
|
let revision: unknown;
|
||||||
|
try {
|
||||||
|
revision = (await res.json())?.revision;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof revision !== "string" || !revision) return;
|
||||||
|
if (revision === snapshot.revision) return;
|
||||||
|
await refreshCatalogTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the tree when this session has none, otherwise ask the server whether
|
||||||
|
* what we hold is still current. The tree used to be read once per session,
|
||||||
|
* which is exactly why a session that started before an import could keep
|
||||||
|
* showing the pre-import catalog.
|
||||||
|
*/
|
||||||
export function ensureCatalogTreeLoaded(): Promise<void> {
|
export function ensureCatalogTreeLoaded(): Promise<void> {
|
||||||
return snapshot.treeLoaded ? Promise.resolve() : refreshCatalogTree();
|
return snapshot.treeLoaded ? checkCatalogRevision() : refreshCatalogTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let channel: BroadcastChannel | null = null;
|
||||||
|
|
||||||
|
function broadcastRevision(revision: string): void {
|
||||||
|
// One tab's refresh tells the others; their own refresh then finds the
|
||||||
|
// revision it already has and stops, so this cannot loop.
|
||||||
|
channel?.postMessage({ revision });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChannel(): BroadcastChannel | null {
|
||||||
|
if (channel || typeof BroadcastChannel === "undefined") return channel;
|
||||||
|
channel = new BroadcastChannel(CHANNEL);
|
||||||
|
channel.addEventListener("message", (event) => {
|
||||||
|
const revision = (event.data as { revision?: unknown } | null)?.revision;
|
||||||
|
if (typeof revision !== "string" || revision === snapshot.revision) return;
|
||||||
|
void refreshCatalogTree();
|
||||||
|
});
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a mounted admin view honest about catalog writes it did not make itself:
|
||||||
|
* load once, then poll the server revision, re-check on focus, and follow
|
||||||
|
* writes from other tabs. Mount it once per view that renders the tree.
|
||||||
|
*/
|
||||||
|
export function useCatalogSync(intervalMs = 20_000): void {
|
||||||
|
useEffect(() => {
|
||||||
|
void ensureCatalogTreeLoaded();
|
||||||
|
getChannel();
|
||||||
|
const recheck = () => {
|
||||||
|
if (document.visibilityState === "visible") void checkCatalogRevision();
|
||||||
|
};
|
||||||
|
const interval = setInterval(recheck, intervalMs);
|
||||||
|
document.addEventListener("visibilitychange", recheck);
|
||||||
|
window.addEventListener("focus", recheck);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener("visibilitychange", recheck);
|
||||||
|
window.removeEventListener("focus", recheck);
|
||||||
|
};
|
||||||
|
}, [intervalMs]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam: drop all live catalog state between cases. */
|
/** Test seam: drop all live catalog state between cases. */
|
||||||
export function resetLiveCatalogForTests(): void {
|
export function resetLiveCatalogForTests(): void {
|
||||||
treeRequest = null;
|
treeRequest = null;
|
||||||
treeRequestQueued = false;
|
treeRequestQueued = false;
|
||||||
|
treeEtag = null;
|
||||||
|
channel?.close();
|
||||||
|
channel = null;
|
||||||
emit(EMPTY_SNAPSHOT);
|
emit(EMPTY_SNAPSHOT);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { catalogStateRoot } from "@/lib/services/catalog-git-config";
|
||||||
|
|
||||||
|
const FILE = "catalog-revision.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cheap "did the catalog change?" token for every open admin session.
|
||||||
|
*
|
||||||
|
* `sendCatalogUpdate()` runs after each catalog write, so bumping this there
|
||||||
|
* covers every import, page edit, item edit and organize run without touching
|
||||||
|
* each call site. Clients compare the revision they loaded against the current
|
||||||
|
* one to decide whether their tree is stale: one tiny file read instead of
|
||||||
|
* re-reading the whole tree every few seconds.
|
||||||
|
*
|
||||||
|
* The value is random rather than a counter. Two CMS processes writing at the
|
||||||
|
* same moment must never hand out the same revision, and consumers only ever
|
||||||
|
* compare for inequality, so a monotonic number would buy nothing.
|
||||||
|
*
|
||||||
|
* Like the totals cache this only tracks writes made through the CMS. A direct
|
||||||
|
* database edit leaves the revision untouched, which is exactly when a reload
|
||||||
|
* is warranted anyway.
|
||||||
|
*/
|
||||||
|
export async function bumpCatalogRevision(): Promise<string> {
|
||||||
|
const revision = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
||||||
|
const root = catalogStateRoot();
|
||||||
|
const temp = path.join(root, `revision-${randomUUID()}.tmp`);
|
||||||
|
try {
|
||||||
|
await fs.mkdir(root, { recursive: true });
|
||||||
|
await fs.writeFile(temp, JSON.stringify({ revision }), { flag: "wx" });
|
||||||
|
await fs.rename(temp, path.join(root, FILE));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Cannot persist catalog revision", {
|
||||||
|
module: "catalog",
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fs.rm(temp, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readCatalogRevision(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
await fs.readFile(path.join(catalogStateRoot(), FILE), "utf8"),
|
||||||
|
) as { revision?: unknown };
|
||||||
|
return typeof parsed.revision === "string" ? parsed.revision : null;
|
||||||
|
} catch (error) {
|
||||||
|
// A missing file means "no revision yet", which every client reads as
|
||||||
|
// stale — the correct answer on a fresh install.
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||||
|
logger.error("Cannot read catalog revision", {
|
||||||
|
module: "catalog",
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { catalogStateRoot } from "@/lib/services/catalog-git-config";
|
import { catalogStateRoot } from "@/lib/services/catalog-git-config";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { bumpCatalogRevision } from "./catalog-revision";
|
||||||
import { invalidateCatalogTotals } from "./catalog-totals";
|
import { invalidateCatalogTotals } from "./catalog-totals";
|
||||||
export interface CatalogHotelStatus {
|
export interface CatalogHotelStatus {
|
||||||
sent: boolean;
|
sent: boolean;
|
||||||
@@ -44,6 +45,10 @@ export async function sendCatalogUpdate(): Promise<CatalogHotelStatus> {
|
|||||||
// Callers reach here only after writing catalog rows, so the admin stats
|
// Callers reach here only after writing catalog rows, so the admin stats
|
||||||
// totals must not keep serving the pre-write numbers from their 30s cache.
|
// totals must not keep serving the pre-write numbers from their 30s cache.
|
||||||
invalidateCatalogTotals();
|
invalidateCatalogTotals();
|
||||||
|
// Every catalog write lands here, which makes this the one place that can
|
||||||
|
// tell open admin sessions their tree is stale — including writes made by a
|
||||||
|
// different tab, browser or process than the one watching.
|
||||||
|
await bumpCatalogRevision();
|
||||||
const status = { sent, checkedAt: new Date().toISOString(), reference };
|
const status = { sent, checkedAt: new Date().toISOString(), reference };
|
||||||
const root = catalogStateRoot();
|
const root = catalogStateRoot();
|
||||||
const temp = path.join(root, `hotel-${randomUUID()}.tmp`);
|
const temp = path.join(root, `hotel-${randomUUID()}.tmp`);
|
||||||
|
|||||||
+5
-2
@@ -40,8 +40,11 @@ export function apiJson(data: unknown, init?: ResponseInit): NextResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Standard success envelope: { ok: true, ...data }. */
|
/** Standard success envelope: { ok: true, ...data }. */
|
||||||
export function apiOk(data?: Record<string, unknown>): NextResponse {
|
export function apiOk(
|
||||||
return apiJson({ ok: true, ...data });
|
data?: Record<string, unknown>,
|
||||||
|
init?: ResponseInit,
|
||||||
|
): NextResponse {
|
||||||
|
return apiJson({ ok: true, ...data }, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Standard error envelope. */
|
/** Standard error envelope. */
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
type OfferPurchasabilityInput,
|
||||||
|
offerPurchasabilityProblems,
|
||||||
|
} from "./offer-purchasability";
|
||||||
|
|
||||||
|
function offer(over: Partial<OfferPurchasabilityInput> = {}) {
|
||||||
|
return {
|
||||||
|
pageEnabled: "1",
|
||||||
|
pageVisible: "1",
|
||||||
|
itemIds: "1234",
|
||||||
|
amount: 1,
|
||||||
|
costCredits: 10,
|
||||||
|
costPoints: 0,
|
||||||
|
offerId: 500,
|
||||||
|
haveOffer: "1",
|
||||||
|
furniId: 1234,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("offerPurchasabilityProblems", () => {
|
||||||
|
it("accepts an offer the hotel can sell", () => {
|
||||||
|
expect(offerPurchasabilityProblems(offer())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a free offer and one without an offer id", () => {
|
||||||
|
expect(offerPurchasabilityProblems(offer({ costCredits: 0 }))).toEqual([]);
|
||||||
|
expect(offerPurchasabilityProblems(offer({ offerId: -1 }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a page the client will not show", () => {
|
||||||
|
expect(offerPurchasabilityProblems(offer({ pageVisible: "0" }))).toEqual([
|
||||||
|
"catalog page is hidden",
|
||||||
|
]);
|
||||||
|
expect(offerPurchasabilityProblems(offer({ pageEnabled: "0" }))).toEqual([
|
||||||
|
"catalog page is disabled",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports item_ids that do not name the imported furni", () => {
|
||||||
|
expect(offerPurchasabilityProblems(offer({ furniId: 9999 }))).toEqual([
|
||||||
|
"item_ids does not contain furni id 9999 (it holds 1234)",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
offerPurchasabilityProblems(offer({ itemIds: " 1234 ; 1235 " })),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(offerPurchasabilityProblems(offer({ itemIds: "" }))).toEqual([
|
||||||
|
"catalog item has no furni id in item_ids",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an offer that would deliver nothing", () => {
|
||||||
|
expect(offerPurchasabilityProblems(offer({ amount: 0 }))).toEqual([
|
||||||
|
"amount is 0, so the offer delivers nothing",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a negative price and a broken offer id", () => {
|
||||||
|
expect(offerPurchasabilityProblems(offer({ costCredits: -1 }))).toEqual([
|
||||||
|
"offer has a negative price",
|
||||||
|
]);
|
||||||
|
expect(offerPurchasabilityProblems(offer({ haveOffer: "0" }))).toEqual([
|
||||||
|
"offer_id is set but haveOffer is not 1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists every problem at once", () => {
|
||||||
|
expect(
|
||||||
|
offerPurchasabilityProblems(
|
||||||
|
offer({ pageVisible: "0", pageEnabled: "0", amount: 0 }),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"catalog page is disabled",
|
||||||
|
"catalog page is hidden",
|
||||||
|
"amount is 0, so the offer delivers nothing",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Why a catalog offer can be "imported successfully" and still be invisible in
|
||||||
|
* the hotel.
|
||||||
|
*
|
||||||
|
* The import writes `catalog_items` plus a `furnidata` entry and reports `ok`.
|
||||||
|
* Whether the emulator will actually sell the offer depends on a handful of
|
||||||
|
* conditions the import never asserts: the page has to be enabled and visible,
|
||||||
|
* `item_ids` has to name the furni id, `amount` has to be positive. Every one of
|
||||||
|
* them looks fine in the CMS and produces a client that shows nothing, which is
|
||||||
|
* reported as "the import did not work".
|
||||||
|
*
|
||||||
|
* This module is the pure part of that check so the rules can be tested without
|
||||||
|
* a database; `importSingleFurni` supplies the row it read back.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OfferPurchasabilityInput {
|
||||||
|
/** Catalog page the offer sits on. */
|
||||||
|
pageEnabled: string;
|
||||||
|
pageVisible: string;
|
||||||
|
/** Raw `catalog_items.item_ids`, `;`-separated. */
|
||||||
|
itemIds: string;
|
||||||
|
amount: number;
|
||||||
|
costCredits: number;
|
||||||
|
costPoints: number;
|
||||||
|
/** `-1` means the offer is not wired up. */
|
||||||
|
offerId: number;
|
||||||
|
haveOffer: string;
|
||||||
|
/** The `catalog_item_base.id` the import just wrote. */
|
||||||
|
furniId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offer id used by Habbo DBs for "this offer has no offer id". */
|
||||||
|
const NO_OFFER_ID = -1;
|
||||||
|
|
||||||
|
function parsesItemIds(itemIds: string): number[] {
|
||||||
|
return itemIds
|
||||||
|
.split(/[;,]/)
|
||||||
|
.map((part) => Number(part.trim()))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problems that stop the hotel from selling the offer, in reporting order. */
|
||||||
|
export function offerPurchasabilityProblems(
|
||||||
|
offer: OfferPurchasabilityInput,
|
||||||
|
): string[] {
|
||||||
|
const problems: string[] = [];
|
||||||
|
const ids = parsesItemIds(offer.itemIds);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
problems.push("catalog item has no furni id in item_ids");
|
||||||
|
} else if (!ids.includes(offer.furniId)) {
|
||||||
|
problems.push(
|
||||||
|
`item_ids does not contain furni id ${offer.furniId} (it holds ${ids.join(", ")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (offer.pageEnabled !== "1") problems.push("catalog page is disabled");
|
||||||
|
if (offer.pageVisible !== "1") problems.push("catalog page is hidden");
|
||||||
|
if (!Number.isFinite(offer.amount) || offer.amount < 1)
|
||||||
|
problems.push(`amount is ${offer.amount}, so the offer delivers nothing`);
|
||||||
|
if (offer.costCredits < 0 || offer.costPoints < 0)
|
||||||
|
problems.push("offer has a negative price");
|
||||||
|
if (
|
||||||
|
offer.offerId !== NO_OFFER_ID &&
|
||||||
|
offer.haveOffer !== "1" &&
|
||||||
|
ids.length > 0
|
||||||
|
) {
|
||||||
|
problems.push("offer_id is set but haveOffer is not 1");
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { and, eq, type SQL, sql } from "drizzle-orm";
|
import { and, eq, type SQL, sql } from "drizzle-orm";
|
||||||
import { CatalogPages, db, execResult, ItemsBase, queryRows } from "@/lib/db";
|
import { CatalogPages, db, execResult, ItemsBase, queryRows } from "@/lib/db";
|
||||||
|
import { offerPurchasabilityProblems } from "@/lib/furni/offer-purchasability";
|
||||||
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
@@ -525,6 +526,55 @@ export async function allocateCatalogItemId<T>(
|
|||||||
|
|
||||||
/** Test seam: drop the cached id counter between tests. */
|
/** Test seam: drop the cached id counter between tests. */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a written offer back and report everything that would keep the hotel from
|
||||||
|
* selling it. Best-effort by design: a failed probe must never fail an import
|
||||||
|
* that already wrote its files, so this returns no problems on error.
|
||||||
|
*/
|
||||||
|
async function catalogOfferProblems(
|
||||||
|
catalogItemId: number,
|
||||||
|
furniId: number,
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const rows = await queryRows<{
|
||||||
|
item_ids: string;
|
||||||
|
amount: number;
|
||||||
|
cost_credits: number;
|
||||||
|
cost_points: number;
|
||||||
|
offer_id: number;
|
||||||
|
haveOffer: string;
|
||||||
|
page_enabled: string;
|
||||||
|
page_visible: string;
|
||||||
|
}>(sql`
|
||||||
|
SELECT ci.item_ids, ci.amount, ci.cost_credits, ci.cost_points, ci.offer_id, ci.haveOffer,
|
||||||
|
cp.enabled AS page_enabled, cp.visible AS page_visible
|
||||||
|
FROM catalog_items ci
|
||||||
|
LEFT JOIN catalog_pages cp ON cp.id = ci.page_id
|
||||||
|
WHERE ci.id = ${catalogItemId}
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return ["catalog entry disappeared right after writing it"];
|
||||||
|
return offerPurchasabilityProblems({
|
||||||
|
pageEnabled: String(row.page_enabled ?? "0"),
|
||||||
|
pageVisible: String(row.page_visible ?? "0"),
|
||||||
|
itemIds: String(row.item_ids ?? ""),
|
||||||
|
amount: Number(row.amount),
|
||||||
|
costCredits: Number(row.cost_credits),
|
||||||
|
costPoints: Number(row.cost_points),
|
||||||
|
offerId: Number(row.offer_id),
|
||||||
|
haveOffer: String(row.haveOffer ?? "0"),
|
||||||
|
furniId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("[import-furni] Cannot verify the imported offer", {
|
||||||
|
error: (error as Error).message,
|
||||||
|
catalogItemId,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function importSingleFurni(params: {
|
export async function importSingleFurni(params: {
|
||||||
id: number;
|
id: number;
|
||||||
classname: string;
|
classname: string;
|
||||||
@@ -1149,6 +1199,25 @@ export async function importSingleFurni(params: {
|
|||||||
warnings.push("Catalog entry creation failed");
|
warnings.push("Catalog entry creation failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (catalogItemId !== null) {
|
||||||
|
// The write above is not proof the hotel will sell anything: a hidden or
|
||||||
|
// disabled page, an `item_ids` that misses the furni id or a zero amount
|
||||||
|
// all import "successfully" and show the player nothing. Read the row back
|
||||||
|
// and say so here, where the import report already shows up, instead of
|
||||||
|
// letting it surface as "the import did not work" in the client.
|
||||||
|
const problems = await catalogOfferProblems(catalogItemId, itemId);
|
||||||
|
for (const problem of problems) {
|
||||||
|
warnings.push(`Offer not purchasable: ${problem}`);
|
||||||
|
}
|
||||||
|
if (problems.length > 0) {
|
||||||
|
logger.warn("[import-furni] Imported offer is not purchasable", {
|
||||||
|
classname,
|
||||||
|
catalogItemId,
|
||||||
|
problems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
itemId,
|
itemId,
|
||||||
|
|||||||
Reference in new issue
Block a user