diff --git a/docs/cms-upgrade-2026-09.md b/docs/cms-upgrade-2026-09.md
index 21234e32bb..7491accc24 100644
--- a/docs/cms-upgrade-2026-09.md
+++ b/docs/cms-upgrade-2026-09.md
@@ -46,3 +46,38 @@ Local verification on 2026-09-09:
- Deployment rollback, verified-digest publication, ownership/cancellation and concurrent news edit behavior have focused regression tests.
Docker runtime, database migration execution and authenticated browser flows still require an integration environment. Check the Gitea pipeline and live release identifier after publication. A successful local build is not production verification.
+
+
+## Catalog packages
+
+The normal catalog toolbar now opens a dedicated Catalog packages dialog.
+Create a named draft from selected categories and their descendants, either to
+update those categories or copy them under a chosen parent. Drafts are shared
+with authorized staff; saving a draft does not modify the live catalog.
+
+Edit category metadata and offer prices, or review bulk price changes before
+applying them to the draft. The catalog preview supports category navigation,
+search, real local furniture icons and rank/Club/VIP access simulation. It does
+not render the game client or evaluate ancestors outside the selected package;
+special layouts and that access limitation are disclosed in the preview.
+
+Publication requires a saved draft and a fresh review. Concurrent source changes
+block publication; version checks prevent one editor overwriting another.
+Copying preserves the underlying category and offer fields and remaps internal
+references while retaining furniture IDs and assets. The catalog writes and
+published result are committed together; retrying the same published package
+does not copy it again. Failures in hotel notifications, audit or Git export
+scheduling after commit are reported as warnings rather than failed publication.
+
+Apply additive migration `0027_catalog_packages.sql` before opening this tool.
+Existing migration automation discovers the file. Limits are 200 categories,
+500 offers and 8 MB of package data. Package source checks inspect at most 20,000
+catalog categories. Publication briefly locks category rows while validating and
+writing changes, so large live catalogs should be checked under realistic load.
+English, Italian and Dutch copy is provided; other locales use the new English
+strings pending translation. No new dependency is required.
+
+Validation: 1,506 tests passed (six skipped), type checking, lint, translation
+contracts and a production build with fixture configuration. A browser fixture
+verified the real dialog at 1280 and 390 pixels; server actions were simulated.
+The new database migration and package publication have not run in production.
diff --git a/drizzle/migrations/0027_catalog_packages.sql b/drizzle/migrations/0027_catalog_packages.sql
new file mode 100644
index 0000000000..8fc53b267a
--- /dev/null
+++ b/drizzle/migrations/0027_catalog_packages.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS website_catalog_packages (
+ id VARCHAR(36) NOT NULL PRIMARY KEY,
+ name VARCHAR(128) NOT NULL,
+ version INT UNSIGNED NOT NULL,
+ status VARCHAR(16) NOT NULL,
+ mode VARCHAR(16) NOT NULL,
+ payload LONGTEXT NOT NULL,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX package_recent (updated_at, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/src/actions/catalog-packages.test.ts b/src/actions/catalog-packages.test.ts
new file mode 100644
index 0000000000..6bd662a42e
--- /dev/null
+++ b/src/actions/catalog-packages.test.ts
@@ -0,0 +1,156 @@
+import { beforeEach, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ permission: vi.fn(),
+ create: vi.fn(),
+ save: vi.fn(),
+ get: vi.fn(),
+ list: vi.fn(),
+ preview: vi.fn(),
+ publish: vi.fn(),
+ export: vi.fn(),
+ audit: vi.fn(),
+ hotel: vi.fn(),
+ refresh: vi.fn(),
+}));
+vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.permission }));
+vi.mock("@/lib/permissions", () => ({
+ PERMS: { CATALOG_VIEW: "view", CATALOG_EDIT: "edit" },
+}));
+vi.mock("@/features/catalog/server/packages", () => ({
+ createCatalogPackageCommand: mocks.create,
+ saveCatalogPackageCommand: mocks.save,
+ getCatalogPackageCommand: mocks.get,
+ listCatalogPackagesCommand: mocks.list,
+ previewCatalogPackageCommand: mocks.preview,
+ publishCatalogPackageCommand: mocks.publish,
+}));
+vi.mock("@/features/catalog/server/package-messages", () => ({
+ packageMessage: async (m: string) => m,
+}));
+vi.mock("@/features/catalog/server/errors", () => ({
+ catalogFailure: (e: Error) => ({ message: e.message }),
+}));
+vi.mock("@/lib/services/catalog-git-queue", () => ({
+ withCatalogExport: mocks.export,
+}));
+vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit }));
+vi.mock("@/features/catalog/server/sync-status", () => ({
+ sendCatalogUpdate: mocks.hotel,
+}));
+vi.mock("next/cache", () => ({ revalidatePath: mocks.refresh }));
+
+import {
+ createCatalogPackage,
+ getCatalogPackage,
+ listCatalogPackages,
+ previewCatalogPackage,
+ publishCatalogPackage,
+ saveCatalogPackage,
+} from "./catalog-packages";
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ mocks.permission.mockResolvedValue({ id: 7 });
+ mocks.export.mockImplementation((fn) => fn());
+ mocks.publish.mockResolvedValue({
+ pageIds: [4],
+ offerCount: 1,
+ alreadyPublished: false,
+ });
+ mocks.hotel.mockResolvedValue({ sent: true });
+ mocks.preview.mockResolvedValue({
+ version: 1,
+ fingerprint: "a".repeat(64),
+ changedPages: 1,
+ changedOffers: 1,
+ conflicts: [],
+ });
+});
+it("keeps draft creation, saving and preview out of live export, audit and hotel updates", async () => {
+ await createCatalogPackage({
+ name: "Pack",
+ pageIds: [1],
+ mode: "copy",
+ targetParentId: -1,
+ });
+ await saveCatalogPackage({
+ id: "id",
+ version: 1,
+ name: "Pack",
+ draft: { pages: [], offers: [] },
+ targetParentId: -1,
+ });
+ await getCatalogPackage("id");
+ await listCatalogPackages();
+ await previewCatalogPackage("id");
+ expect(mocks.permission.mock.calls.map((c) => c[0])).toEqual([
+ "edit",
+ "edit",
+ "view",
+ "view",
+ "view",
+ ]);
+ expect(mocks.export).not.toHaveBeenCalled();
+ expect(mocks.audit).not.toHaveBeenCalled();
+ expect(mocks.hotel).not.toHaveBeenCalled();
+});
+it("reports committed publication with warnings if audit and hotel updates fail", async () => {
+ mocks.audit.mockRejectedValue(Error("audit down"));
+ mocks.hotel.mockRejectedValue(Error("hotel down"));
+ const result = await publishCatalogPackage({
+ id: "id",
+ version: 1,
+ fingerprint: "a".repeat(64),
+ });
+ expect(result).toMatchObject({
+ ok: true,
+ data: { pageIds: [4], warnings: expect.any(Array) },
+ });
+ if (result.ok) expect(result.data.warnings).toHaveLength(2);
+ expect(mocks.permission).toHaveBeenCalledWith("edit");
+});
+it("does not report a rollback if an export wrapper fails after commit", async () => {
+ mocks.export.mockImplementation(async (fn) => {
+ await fn();
+ throw Error("export failed");
+ });
+ expect(
+ await publishCatalogPackage({
+ id: "id",
+ version: 1,
+ fingerprint: "a".repeat(64),
+ }),
+ ).toMatchObject({
+ ok: true,
+ data: { warnings: ["Package published, but export scheduling failed."] },
+ });
+});
+it("reports a transaction failure without sending hotel updates or audit", async () => {
+ mocks.publish.mockRejectedValue(Error("live conflict"));
+ expect(
+ await publishCatalogPackage({
+ id: "id",
+ version: 1,
+ fingerprint: "a".repeat(64),
+ }),
+ ).toEqual({ ok: false, error: "live conflict" });
+ expect(mocks.audit).not.toHaveBeenCalled();
+ expect(mocks.hotel).not.toHaveBeenCalled();
+});
+it("does not repeat external side effects for an already published package", async () => {
+ mocks.publish.mockResolvedValue({
+ pageIds: [4],
+ offerCount: 1,
+ alreadyPublished: true,
+ });
+ expect(
+ await publishCatalogPackage({
+ id: "id",
+ version: 1,
+ fingerprint: "a".repeat(64),
+ }),
+ ).toMatchObject({ ok: true, data: { alreadyPublished: true } });
+ expect(mocks.audit).not.toHaveBeenCalled();
+ expect(mocks.hotel).not.toHaveBeenCalled();
+});
diff --git a/src/actions/catalog-packages.ts b/src/actions/catalog-packages.ts
new file mode 100644
index 0000000000..6ad2d6a112
--- /dev/null
+++ b/src/actions/catalog-packages.ts
@@ -0,0 +1,123 @@
+"use server";
+import { revalidatePath } from "next/cache";
+import type {
+ CreateCatalogPackageInput,
+ PackagePublication,
+ PublishCatalogPackageInput,
+ SaveCatalogPackageInput,
+} from "@/features/catalog/domain/packages";
+import { catalogFailure } from "@/features/catalog/server/errors";
+import { packageMessage } from "@/features/catalog/server/package-messages";
+import {
+ createCatalogPackageCommand,
+ getCatalogPackageCommand,
+ listCatalogPackagesCommand,
+ previewCatalogPackageCommand,
+ publishCatalogPackageCommand,
+ saveCatalogPackageCommand,
+} from "@/features/catalog/server/packages";
+import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
+import { requirePermission } from "@/lib/admin/guard";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { withCatalogExport } from "@/lib/services/catalog-git-queue";
+
+async function attempt(operation: () => Promise) {
+ try {
+ return { ok: true as const, data: await operation() };
+ } catch (error) {
+ return {
+ ok: false as const,
+ error: await packageMessage(catalogFailure(error).message),
+ };
+ }
+}
+export async function listCatalogPackages() {
+ await requirePermission(PERMS.CATALOG_VIEW);
+ return attempt(listCatalogPackagesCommand);
+}
+export async function getCatalogPackage(id: string) {
+ await requirePermission(PERMS.CATALOG_VIEW);
+ return attempt(() => getCatalogPackageCommand(id));
+}
+export async function createCatalogPackage(input: CreateCatalogPackageInput) {
+ await requirePermission(PERMS.CATALOG_EDIT);
+ return attempt(() => createCatalogPackageCommand(input));
+}
+export async function saveCatalogPackage(input: SaveCatalogPackageInput) {
+ await requirePermission(PERMS.CATALOG_EDIT);
+ return attempt(() => saveCatalogPackageCommand(input));
+}
+export async function previewCatalogPackage(id: string) {
+ await requirePermission(PERMS.CATALOG_VIEW);
+ return attempt(async () => {
+ const data = await previewCatalogPackageCommand(id);
+ return {
+ ...data,
+ conflicts: await Promise.all(data.conflicts.map(packageMessage)),
+ };
+ });
+}
+export async function publishCatalogPackage(input: PublishCatalogPackageInput) {
+ const staff = await requirePermission(PERMS.CATALOG_EDIT);
+ return attempt(async () => {
+ let committed: PackagePublication | undefined;
+ const warnings: string[] = [];
+ try {
+ await withCatalogExport(async () => {
+ committed = await publishCatalogPackageCommand(input);
+ return committed;
+ });
+ } catch (error) {
+ if (!committed) throw error;
+ warnings.push("Package published, but export scheduling failed.");
+ }
+ if (!committed) throw Error("Package publication did not return a result");
+ const result = committed;
+ if (result.alreadyPublished)
+ return {
+ ...result,
+ ...(warnings.length
+ ? { warnings: await Promise.all(warnings.map(packageMessage)) }
+ : {}),
+ };
+ try {
+ await logAudit({
+ userId: staff.id,
+ action: "catalog_package_publish",
+ target: "catalog_package",
+ after: {
+ packageId: input.id,
+ pageIds: result.pageIds,
+ offerCount: result.offerCount,
+ },
+ });
+ } catch {
+ warnings.push("Package published, but audit logging failed.");
+ }
+ try {
+ const hotel = await sendCatalogUpdate();
+ if (!hotel.sent)
+ warnings.push(
+ "Package published, but the hotel update was not delivered.",
+ );
+ } catch {
+ warnings.push(
+ "Package published, but the hotel update was not delivered.",
+ );
+ }
+ try {
+ revalidatePath("/admin/catalog");
+ } catch {
+ warnings.push(
+ "Package published. Refresh the page to see the latest catalog.",
+ );
+ }
+ return {
+ ...result,
+ ...(warnings.length
+ ? { warnings: await Promise.all(warnings.map(packageMessage)) }
+ : {}),
+ } satisfies PackagePublication;
+ });
+}
diff --git a/src/actions/catalog-preview-assets.test.ts b/src/actions/catalog-preview-assets.test.ts
new file mode 100644
index 0000000000..a379305e10
--- /dev/null
+++ b/src/actions/catalog-preview-assets.test.ts
@@ -0,0 +1,46 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({
+ allowed: true,
+ query: vi.fn(async () => [{ id: 2, classname: "sofa", name: "Sofa" }]),
+}));
+vi.mock("@/lib/admin/guard", () => ({
+ requirePermission: vi.fn(async () => {
+ if (!state.allowed) throw new Error("forbidden");
+ }),
+}));
+vi.mock("@/lib/permissions", () => ({
+ PERMS: { CATALOG_VIEW: "catalog.view" },
+}));
+vi.mock("@/lib/db", () => ({
+ ItemsBase: { id: "id", itemName: "itemName", publicName: "publicName" },
+ db: { select: () => ({ from: () => ({ where: state.query }) }) },
+}));
+
+import { requirePermission } from "@/lib/admin/guard";
+import { getCatalogPreviewAssets } from "./catalog-preview-assets";
+
+describe("preview asset authorization and bounds", () => {
+ beforeEach(() => {
+ state.allowed = true;
+ state.query.mockClear();
+ });
+ it("checks catalog viewing permission before querying", async () => {
+ state.allowed = false;
+ await expect(getCatalogPreviewAssets([2])).rejects.toThrow("forbidden");
+ expect(requirePermission).toHaveBeenCalledWith("catalog.view");
+ expect(state.query).not.toHaveBeenCalled();
+ });
+ it("rejects oversized and invalid requests without querying", async () => {
+ expect((await getCatalogPreviewAssets(Array(201).fill(2))).ok).toBe(false);
+ expect((await getCatalogPreviewAssets([NaN, -2])).ok).toBe(false);
+ expect(state.query).not.toHaveBeenCalled();
+ });
+ it("returns only database-backed classname and display name", async () => {
+ expect(await getCatalogPreviewAssets([2, 2])).toEqual({
+ ok: true,
+ assets: [{ id: 2, classname: "sofa", name: "Sofa" }],
+ });
+ expect(state.query).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/actions/catalog-preview-assets.ts b/src/actions/catalog-preview-assets.ts
new file mode 100644
index 0000000000..18d291d655
--- /dev/null
+++ b/src/actions/catalog-preview-assets.ts
@@ -0,0 +1,31 @@
+"use server";
+import { inArray } from "drizzle-orm";
+import { requirePermission } from "@/lib/admin/guard";
+import { db, ItemsBase } from "@/lib/db";
+import { PERMS } from "@/lib/permissions";
+export async function getCatalogPreviewAssets(itemIds: number[]) {
+ await requirePermission(PERMS.CATALOG_VIEW);
+ if (
+ !Array.isArray(itemIds) ||
+ itemIds.length > 200 ||
+ itemIds.some(
+ (id) => !Number.isSafeInteger(id) || id <= 0 || id > 2147483647,
+ )
+ )
+ return { ok: false as const, assets: [] };
+ const ids = [...new Set(itemIds)];
+ if (!ids.length) return { ok: true as const, assets: [] };
+ try {
+ const assets = await db
+ .select({
+ id: ItemsBase.id,
+ classname: ItemsBase.itemName,
+ name: ItemsBase.publicName,
+ })
+ .from(ItemsBase)
+ .where(inArray(ItemsBase.id, ids));
+ return { ok: true as const, assets };
+ } catch {
+ return { ok: false as const, assets: [] };
+ }
+}
diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx
index c63be7610e..d739b0906b 100644
--- a/src/app/admin/catalog/page.tsx
+++ b/src/app/admin/catalog/page.tsx
@@ -19,6 +19,7 @@ import { CatalogTree } from "@/components/admin/catalog-tree";
import Link from "@/components/link";
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
+import { CatalogPackagesDialog } from "@/features/catalog/components/packages/catalog-packages-dialog";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
CatalogItems,
@@ -506,6 +507,7 @@ export default async function CatalogPagesPage({
+
{canEdit && (
({
+ id,
+ parentId,
+ caption: `Page ${id}`,
+ enabled: "1" as const,
+ visible: "1" as const,
+ iconImage: 0,
+ minRank: 1,
+ clubOnly: "0" as const,
+ vipOnly: "0" as const,
+ orderNum: id,
+ pageLayout: "default_3x3",
+ ...overrides,
+});
+const profile = { playerMode: true, rank: 1, club: false, vip: false };
+describe("package preview access", () => {
+ it("honors restrictions on every included ancestor", () => {
+ const result = buildPackagePreview(
+ { pages: [page(1, -1, { minRank: 5 }), page(2, 1)], offers: [] },
+ profile,
+ "",
+ );
+ expect(result.pages).toEqual([]);
+ expect(result.restrictedCount).toBe(2);
+ });
+ it("handles hidden/disabled ancestors and club membership", () => {
+ for (const restriction of [
+ { visible: "0" },
+ { enabled: "0" },
+ { clubOnly: "1" },
+ { vipOnly: "1" },
+ ]) {
+ expect(
+ buildPackagePreview(
+ { pages: [page(1, -1, restriction), page(2, 1)], offers: [] },
+ profile,
+ "",
+ ).pages,
+ ).toEqual([]);
+ }
+ expect(
+ buildPackagePreview(
+ { pages: [page(1, -1, { clubOnly: "1" })], offers: [] },
+ { ...profile, vip: true },
+ "",
+ ).pages,
+ ).toHaveLength(1);
+ });
+ it("rejects cycles without hanging and reports external subtree boundaries", () => {
+ const result = buildPackagePreview(
+ { pages: [page(1, 2), page(2, 1), page(3, 999)], offers: [] },
+ profile,
+ "",
+ );
+ expect(result.pages.map((p) => p.id)).toEqual([3]);
+ expect(result.invalidCount).toBe(2);
+ expect(result.externalParents).toBe(true);
+ });
+ it("bounds oversized snapshots before traversing", () => {
+ const result = buildPackagePreview(
+ { pages: Array.from({ length: 500 }, (_, i) => page(i + 1)), offers: [] },
+ profile,
+ "",
+ );
+ expect(result.pages).toHaveLength(200);
+ expect(result.truncated).toBe(true);
+ });
+ it("preserves parent navigation when only a child offer matches", () => {
+ const result = buildPackagePreview(
+ {
+ pages: [page(1), page(2, 1)],
+ offers: [
+ {
+ id: 1,
+ pageId: 2,
+ catalogName: "Sofa",
+ itemIds: "4;5",
+ costCredits: 2,
+ costPoints: 0,
+ pointsType: 0,
+ amount: 1,
+ },
+ ],
+ },
+ profile,
+ "sofa",
+ );
+ expect(result.pages.map((p) => p.id)).toEqual([1, 2]);
+ expect(result.matchingPageIds).toEqual([2]);
+ });
+ it("parses bundle IDs strictly without treating partial numbers as IDs", () => {
+ expect(parsePreviewItemIds("2; 3,4;2;abc;5x;-1;0")).toEqual([2, 3, 4]);
+ });
+});
+describe("preview asset requests", () => {
+ it("ignores older responses after changing selection", async () => {
+ const resolves: ((value: number[]) => void)[] = [];
+ const loader = createPreviewAssetLoader(
+ () => new Promise((resolve) => resolves.push(resolve)),
+ );
+ const seen: number[][] = [];
+ const first = loader.run([1], (value) => seen.push(value));
+ const second = loader.run([2], (value) => seen.push(value));
+ resolves[1]([2]);
+ await second;
+ resolves[0]([1]);
+ await first;
+ expect(seen).toEqual([[2]]);
+ });
+ it("does not publish after unmount/cancellation", async () => {
+ let finish = (_value: number[]) => {};
+ const loader = createPreviewAssetLoader(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ const seen: number[][] = [];
+ const pending = loader.run([1], (value) => seen.push(value));
+ loader.cancel();
+ finish([1]);
+ await pending;
+ expect(seen).toEqual([]);
+ });
+});
+
+describe("offer membership access", () => {
+ it("filters Club offers separately from their public category and permits VIP", () => {
+ const snapshot = {
+ pages: [page(1)],
+ offers: [
+ {
+ id: 1,
+ pageId: 1,
+ catalogName: "Club chair",
+ clubOnly: "1" as const,
+ itemIds: "2",
+ costCredits: 1,
+ costPoints: 0,
+ pointsType: 0,
+ amount: 1,
+ },
+ ],
+ };
+ const guest = buildPackagePreview(snapshot, profile, "");
+ expect(guest.pages).toHaveLength(1);
+ expect(guest.offers).toHaveLength(0);
+ expect(guest.restrictedOfferCounts[1]).toBe(1);
+ expect(
+ buildPackagePreview(snapshot, { ...profile, vip: true }, "").offers,
+ ).toHaveLength(1);
+ expect(
+ buildPackagePreview(snapshot, { ...profile, playerMode: false }, "")
+ .offers,
+ ).toHaveLength(1);
+ });
+});
diff --git a/src/features/catalog/components/packages/catalog-package-preview-model.ts b/src/features/catalog/components/packages/catalog-package-preview-model.ts
new file mode 100644
index 0000000000..8f703d4e61
--- /dev/null
+++ b/src/features/catalog/components/packages/catalog-package-preview-model.ts
@@ -0,0 +1,153 @@
+import type { PackageSnapshot } from "@/features/catalog/domain/packages";
+export interface PreviewProfile {
+ playerMode: boolean;
+ rank: number;
+ club: boolean;
+ vip: boolean;
+}
+export function parsePreviewItemIds(value: string) {
+ return [
+ ...new Set(
+ value
+ .slice(0, 4096)
+ .split(/[;,]/)
+ .map((part) => part.trim())
+ .filter((part) => /^[1-9]\d*$/.test(part))
+ .map(Number)
+ .filter((id) => Number.isSafeInteger(id) && id <= 2147483647),
+ ),
+ ].slice(0, 200);
+}
+export function buildPackagePreview(
+ snapshot: PackageSnapshot,
+ profile: PreviewProfile,
+ query: string,
+) {
+ const inputPages = snapshot.pages.slice(0, 200);
+ const offers = snapshot.offers.slice(0, 500);
+ const byId = new Map(inputPages.map((page) => [page.id, page]));
+ const valid: typeof inputPages = [];
+ let invalidCount = 0;
+ let restrictedCount = 0;
+ let externalParents = false;
+ const rank = Number.isFinite(profile.rank) ? Math.max(0, profile.rank) : 0;
+ for (const page of inputPages) {
+ const seen = new Set();
+ let current: typeof page | undefined = page;
+ let restricted = false;
+ let invalid = false;
+ while (current) {
+ if (seen.has(current.id)) {
+ invalid = true;
+ break;
+ }
+ seen.add(current.id);
+ if (
+ current.enabled !== "1" ||
+ current.visible !== "1" ||
+ current.minRank > rank ||
+ (current.clubOnly === "1" && !profile.club && !profile.vip) ||
+ (current.vipOnly === "1" && !profile.vip)
+ )
+ restricted = true;
+ if (current.parentId > 0 && !byId.has(current.parentId))
+ externalParents = true;
+ current = byId.get(current.parentId);
+ }
+ if (invalid) invalidCount += 1;
+ else if (profile.playerMode && restricted) restrictedCount += 1;
+ else valid.push(page);
+ }
+ const allowedIds = new Set(valid.map((page) => page.id));
+ const restrictedOfferCounts: Record = {};
+ const visibleOffers = offers.filter((offer) => {
+ if (!allowedIds.has(offer.pageId)) return false;
+ if (
+ profile.playerMode &&
+ offer.clubOnly === "1" &&
+ !profile.club &&
+ !profile.vip
+ ) {
+ restrictedOfferCounts[offer.pageId] =
+ (restrictedOfferCounts[offer.pageId] ?? 0) + 1;
+ return false;
+ }
+ return true;
+ });
+ const normalized = query.trim().toLowerCase().slice(0, 191);
+ const matchingPageIds = valid
+ .filter(
+ (page) =>
+ !normalized ||
+ page.caption.toLowerCase().includes(normalized) ||
+ visibleOffers.some(
+ (offer) =>
+ offer.pageId === page.id &&
+ `${offer.catalogName} ${offer.id} ${offer.itemIds}`
+ .toLowerCase()
+ .includes(normalized),
+ ),
+ )
+ .map((page) => page.id);
+ const shownIds = new Set(matchingPageIds);
+ for (const id of matchingPageIds) {
+ let parent = byId.get(id)?.parentId;
+ while (
+ parent !== undefined &&
+ allowedIds.has(parent) &&
+ !shownIds.has(parent)
+ ) {
+ shownIds.add(parent);
+ parent = byId.get(parent)?.parentId;
+ }
+ }
+ const ordered: Array<(typeof inputPages)[number] & { depth: number }> = [];
+ const children = new Map();
+ const roots: typeof inputPages = [];
+ for (const page of valid.filter((page) => shownIds.has(page.id))) {
+ if (!shownIds.has(page.parentId)) roots.push(page);
+ else
+ children.set(page.parentId, [
+ ...(children.get(page.parentId) ?? []),
+ page,
+ ]);
+ }
+ const sort = (pages: typeof inputPages) =>
+ pages.sort((a, b) => a.orderNum - b.orderNum || a.id - b.id);
+ const walk = (pages: typeof inputPages, depth: number) => {
+ for (const page of sort(pages)) {
+ ordered.push({ ...page, depth });
+ walk(children.get(page.id) ?? [], depth + 1);
+ }
+ };
+ walk(roots, 0);
+ return {
+ pages: ordered,
+ offers: visibleOffers,
+ restrictedOfferCounts,
+ matchingPageIds,
+ invalidCount,
+ restrictedCount,
+ externalParents,
+ truncated: snapshot.pages.length > 200 || snapshot.offers.length > 500,
+ };
+}
+export function createPreviewAssetLoader(
+ load: (ids: number[]) => Promise,
+) {
+ let sequence = 0;
+ return {
+ async run(ids: number[], publish: (value: T) => void, fail?: () => void) {
+ const request = ++sequence;
+ try {
+ const value = await load(ids);
+ if (request === sequence) publish(value);
+ } catch {
+ if (request === sequence) fail?.();
+ }
+ },
+ cancel() {
+ sequence += 1;
+ },
+ };
+}
diff --git a/src/features/catalog/components/packages/catalog-package-preview.tsx b/src/features/catalog/components/packages/catalog-package-preview.tsx
new file mode 100644
index 0000000000..75c4da783d
--- /dev/null
+++ b/src/features/catalog/components/packages/catalog-package-preview.tsx
@@ -0,0 +1,483 @@
+"use client";
+import { useTranslations } from "next-intl";
+import { useEffect, useId, useMemo, useState } from "react";
+import { getCatalogPreviewAssets } from "@/actions/catalog-preview-assets";
+import { CurrencyIcon } from "@/components/shared/currency-icon";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import type {
+ PackageOffer,
+ PackageSnapshot,
+} from "@/features/catalog/domain/packages";
+import { getLocalIconUrl } from "@/lib/furni/classname";
+import {
+ buildPackagePreview,
+ createPreviewAssetLoader,
+ parsePreviewItemIds,
+} from "./catalog-package-preview-model";
+
+type Asset = { id: number; classname: string; name: string };
+function PreviewIcon({ asset, missing }: { asset?: Asset; missing: string }) {
+ const [failed, setFailed] = useState(false);
+ if (!asset?.classname || failed)
+ return (
+
+ {missing}
+
+ );
+ return (
+
setFailed(true)}
+ />
+ );
+}
+function PreviewPrice({ offer }: { offer: PackageOffer }) {
+ const t = useTranslations("pages.admin.catalogPackagePreview");
+ if (offer.costCredits < 0 || offer.costPoints < 0)
+ return (
+
+ {t("specialPricing", {
+ credits: offer.costCredits,
+ points: offer.costPoints,
+ type: offer.pointsType,
+ })}
+
+ );
+ return (
+
+ {offer.costCredits > 0 && (
+
+
+ {offer.costCredits}
+
+ )}
+ {offer.costPoints > 0 && (
+
+ {offer.pointsType === 0 || offer.pointsType === 5 ? (
+
+ ) : (
+ {t("pointsType", { type: offer.pointsType })}
+ )}
+ {offer.costPoints}
+
+ )}
+ {offer.costCredits === 0 && offer.costPoints === 0 && (
+ {t("free")}
+ )}
+
+ );
+}
+function offerChanged(offer: PackageOffer, baseline?: PackageSnapshot) {
+ if (!baseline) return false;
+ const previous = baseline.offers.find((item) => item.id === offer.id);
+ return (
+ !previous ||
+ (Object.keys(offer) as Array).some(
+ (key) => offer[key] !== previous[key],
+ )
+ );
+}
+export function CatalogPackagePreview({
+ snapshot,
+ baseline,
+}: {
+ snapshot: PackageSnapshot;
+ baseline?: PackageSnapshot;
+}) {
+ const t = useTranslations("pages.admin.catalogPackagePreview");
+ const controlId = useId();
+ const [query, setQuery] = useState("");
+ const [profile, setProfile] = useState({
+ playerMode: true,
+ rank: 1,
+ club: false,
+ vip: false,
+ });
+ const [selectedPageId, setSelectedPageId] = useState(null);
+ const [selectedOfferId, setSelectedOfferId] = useState(null);
+ const [pagination, setPagination] = useState({ scope: "", index: 0 });
+ const preview = useMemo(
+ () => buildPackagePreview(snapshot, profile, query),
+ [snapshot, profile, query],
+ );
+ const selectedPage =
+ preview.pages.find((page) => page.id === selectedPageId) ??
+ preview.pages.find((page) => page.id === preview.matchingPageIds[0]);
+ const normalized = query.trim().toLowerCase();
+ const offers = preview.offers.filter(
+ (offer) =>
+ offer.pageId === selectedPage?.id &&
+ (!normalized ||
+ selectedPage.caption.toLowerCase().includes(normalized) ||
+ `${offer.catalogName} ${offer.id} ${offer.itemIds}`
+ .toLowerCase()
+ .includes(normalized)),
+ );
+ const scope = `${selectedPage?.id}:${query}`;
+ const lastIndex = Math.max(0, Math.ceil(offers.length / 48) - 1);
+ const index =
+ pagination.scope === scope ? Math.min(pagination.index, lastIndex) : 0;
+ const visibleOffers = offers.slice(index * 48, (index + 1) * 48);
+ const selectedOffer = offers.find((offer) => offer.id === selectedOfferId);
+ const allItemIds = [
+ ...new Set(
+ [...visibleOffers, ...(selectedOffer ? [selectedOffer] : [])].flatMap(
+ (offer) => parsePreviewItemIds(offer.itemIds),
+ ),
+ ),
+ ];
+ const assetKey = allItemIds
+ .slice(0, 200)
+ .sort((a, b) => a - b)
+ .join(",");
+ const loader = useMemo(
+ () => createPreviewAssetLoader(getCatalogPreviewAssets),
+ [],
+ );
+ const [assetState, setAssetState] = useState<{
+ key: string;
+ status: "ok" | "failed";
+ assets: Asset[];
+ }>({ key: "", status: "ok", assets: [] });
+ useEffect(() => {
+ if (!assetKey) return;
+ void loader.run(
+ assetKey.split(",").map(Number),
+ (result) =>
+ setAssetState({
+ key: assetKey,
+ status: result.ok ? "ok" : "failed",
+ assets: result.assets,
+ }),
+ () => setAssetState({ key: assetKey, status: "failed", assets: [] }),
+ );
+ return () => loader.cancel();
+ }, [assetKey, loader]);
+ const assets = new Map(
+ (assetState.key === assetKey ? assetState.assets : []).map((asset) => [
+ asset.id,
+ asset,
+ ]),
+ );
+ const genericLayout =
+ selectedPage &&
+ [
+ "default_3x3",
+ "default_3x3_color_grouping",
+ "default_3x3_pet",
+ "default_3x3_wallpaper",
+ "default_3x3_floor",
+ "default_3x3_landscape",
+ ].includes(selectedPage.pageLayout);
+ return (
+
+ {t("hint")}
+
+
+
+
+
+
+
+ {preview.externalParents && (
+ {t("externalParents")}
+ )}
+ {preview.invalidCount > 0 && (
+
+ {t("invalidHierarchy", { count: preview.invalidCount })}
+
+ )}
+ {preview.truncated && (
+
+ {t("snapshotLimit")}
+
+ )}
+ {preview.restrictedCount > 0 && (
+
+ {t("restricted", { count: preview.restrictedCount })}
+
+ )}
+
+
+
+ {selectedPage && (
+ <>
+
+
+ {selectedPage.caption}
+
+
+ {t("offerCount", { count: offers.length })}
+
+
+
+ {t("layout", {
+ layout: selectedPage.pageLayout || t("missingLayout"),
+ })}
+
+ {!genericLayout && (
+
+ {t("unsupportedLayout")}
+
+ )}
+ {assetKey && assetState.key !== assetKey && (
+
+ {t("loadingIcons")}
+
+ )}
+ {assetState.key === assetKey &&
+ assetState.status === "failed" && (
+
+ {t("assetsUnavailable")}
+
+ )}
+ {allItemIds.length > 200 && (
+
+ {t("assetLimit")}
+
+ )}
+ {(preview.restrictedOfferCounts[selectedPage.id] ?? 0) > 0 && (
+
+ {t("restrictedOffers", {
+ count: preview.restrictedOfferCounts[selectedPage.id],
+ })}
+
+ )}
+ {offers.length === 0 ? (
+
+ {query
+ ? t("noMatches")
+ : (preview.restrictedOfferCounts[selectedPage.id] ?? 0) > 0
+ ? t("restrictedOffers", {
+ count: preview.restrictedOfferCounts[selectedPage.id],
+ })
+ : t("emptyPage")}
+
+ ) : (
+
+ {visibleOffers.map((offer) => {
+ const ids = parsePreviewItemIds(offer.itemIds);
+ const asset = assets.get(ids[0]);
+ return (
+
+ );
+ })}
+
+ )}
+ {lastIndex > 0 && (
+
+
+
+ {t("page", { page: index + 1, total: lastIndex + 1 })}
+
+
+
+ )}
+ >
+ )}
+ {selectedOffer ? (
+
+
+ {selectedOffer.catalogName || `#${selectedOffer.id}`}
+
+
+ {t("offerId", { id: selectedOffer.id })} ·{" "}
+ {t("amount", { count: selectedOffer.amount })}
+
+
+
+ {parsePreviewItemIds(selectedOffer.itemIds).map((id) => (
+ -
+ {assets.get(id)?.name || t("unknownFurniture")} · #{id}
+
+ ))}
+
+ {offerChanged(selectedOffer, baseline) && (
+
{t("changed")}
+ )}
+
+ ) : (
+ selectedPage && (
+
+ {t("selectOffer")}
+
+ )
+ )}
+
+
+
+ );
+}
diff --git a/src/features/catalog/components/packages/catalog-packages-dialog.tsx b/src/features/catalog/components/packages/catalog-packages-dialog.tsx
new file mode 100644
index 0000000000..69764b2c95
--- /dev/null
+++ b/src/features/catalog/components/packages/catalog-packages-dialog.tsx
@@ -0,0 +1,632 @@
+"use client";
+import { useTranslations } from "next-intl";
+import { useRef, useState } from "react";
+import { getBulkOfferDestinations } from "@/actions/catalog-bulk";
+import {
+ createCatalogPackage,
+ getCatalogPackage,
+ listCatalogPackages,
+ previewCatalogPackage,
+ publishCatalogPackage,
+ saveCatalogPackage,
+} from "@/actions/catalog-packages";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ type CatalogPackage,
+ type PackageListRow,
+ type PackagePreview,
+ type PackagePublication,
+ type PackageSnapshot,
+ savePackageSchema,
+} from "@/features/catalog/domain/packages";
+import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
+import { CatalogPackagePreview } from "./catalog-package-preview";
+import { PackageCategoryPicker } from "./package-category-picker";
+import {
+ packageChanges,
+ packagePublicationIsCurrent,
+} from "./package-editor-helpers";
+import { PackageSnapshotEditor } from "./package-snapshot-editor";
+
+export function CatalogPackagesDialog({ canEdit }: { canEdit: boolean }) {
+ const t = useTranslations("pages.admin.catalog.packages");
+ const [open, setOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const busyRef = useRef(false);
+ const [error, setError] = useState("");
+ const [notice, setNotice] = useState("");
+ const [packages, setPackages] = useState([]);
+ const [categories, setCategories] = useState<
+ Array<{ id: number; caption: string }>
+ >([]);
+ const [listQuery, setListQuery] = useState("");
+ const [listPage, setListPage] = useState(0);
+ const [saved, setSaved] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const [name, setName] = useState("");
+ const [target, setTarget] = useState(-1);
+ const [creating, setCreating] = useState(false);
+ const [mode, setMode] = useState<"update" | "copy">("update");
+ const [pageIds, setPageIds] = useState([]);
+ const [revision, setRevision] = useState(0);
+ const [tab, setTab] = useState<"edit" | "preview" | "review">("edit");
+ const [previewBaseline, setPreviewBaseline] = useState(false);
+ const [review, setReview] = useState(null);
+ const [reviewPage, setReviewPage] = useState(0);
+ const [publication, setPublication] = useState(
+ null,
+ );
+ const dirty = creating
+ ? Boolean(name || pageIds.length || mode !== "update" || target !== -1)
+ : Boolean(
+ saved &&
+ draft &&
+ (saved.name !== name ||
+ saved.targetParentId !== target ||
+ JSON.stringify(saved.draft) !== JSON.stringify(draft)),
+ );
+ useUnsavedChanges(open && dirty, t("discardChanges"));
+ const editable = canEdit && saved?.status === "draft" && !publication;
+ const matches = packages.filter((row) =>
+ `${row.name} ${row.id}`.toLowerCase().includes(listQuery.toLowerCase()),
+ );
+ const changes = saved && draft ? packageChanges(saved.baseline, draft) : [];
+ function invalidate() {
+ setReview(null);
+ setPublication(null);
+ setNotice("");
+ setRevision((value) => value + 1);
+ }
+ function mayLeave() {
+ return !busyRef.current && (!dirty || window.confirm(t("discardChanges")));
+ }
+ async function run(operation: () => Promise) {
+ if (busyRef.current) return;
+ busyRef.current = true;
+ setBusy(true);
+ setError("");
+ try {
+ await operation();
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : t("requestFailed"));
+ } finally {
+ busyRef.current = false;
+ setBusy(false);
+ }
+ }
+ function selectPackage(value: CatalogPackage) {
+ setSaved(value);
+ setDraft(value.draft);
+ setName(value.name);
+ setTarget(value.targetParentId);
+ setCreating(false);
+ setReview(null);
+ setPublication(null);
+ setTab("edit");
+ setRevision((value) => value + 1);
+ setNotice("");
+ }
+ async function refreshList() {
+ const result = await listCatalogPackages();
+ if (!result.ok) throw new Error(result.error);
+ setPackages(result.data);
+ }
+ async function start() {
+ setOpen(true);
+ await run(async () => {
+ const results = await Promise.allSettled([
+ refreshList(),
+ getBulkOfferDestinations(),
+ ]);
+ if (results[0].status === "rejected") throw results[0].reason;
+ const destinations = results[1];
+ if (destinations.status === "rejected") throw destinations.reason;
+ if (!destinations.value.ok) throw new Error(destinations.value.error);
+ setCategories(destinations.value.data.pages);
+ });
+ }
+ function newPackage() {
+ if (!mayLeave()) return;
+ setSaved(null);
+ setDraft(null);
+ setName("");
+ setTarget(-1);
+ setMode("update");
+ setPageIds([]);
+ setCreating(true);
+ setReview(null);
+ setPublication(null);
+ setNotice("");
+ setError("");
+ }
+ async function openPackage(id: string) {
+ if (!mayLeave()) return;
+ await run(async () => {
+ const result = await getCatalogPackage(id);
+ if (!result.ok) throw new Error(result.error);
+ selectPackage(result.data);
+ });
+ }
+ async function save() {
+ if (!saved || !draft || !editable) return;
+ const input = savePackageSchema.safeParse({
+ id: saved.id,
+ version: saved.version,
+ name,
+ draft,
+ targetParentId: target,
+ });
+ if (!input.success) {
+ setError(t("invalidFields"));
+ return;
+ }
+ await run(async () => {
+ const result = await saveCatalogPackage(input.data);
+ if (!result.ok) throw new Error(result.error);
+ selectPackage(result.data);
+ setNotice(t("draftSaved"));
+ await refreshList();
+ });
+ }
+ async function requestReview() {
+ if (!saved || dirty || !editable) return;
+ setReview(null);
+ await run(async () => {
+ const result = await previewCatalogPackage(saved.id);
+ if (!result.ok) throw new Error(result.error);
+ if (result.data.version !== saved.version) {
+ setReview(null);
+ throw new Error(t("staleReview"));
+ }
+ setReview(result.data);
+ setReviewPage(0);
+ setTab("review");
+ });
+ }
+ async function publish() {
+ if (
+ !saved ||
+ !review ||
+ dirty ||
+ !packagePublicationIsCurrent(saved, review, dirty) ||
+ !editable ||
+ !window.confirm(t("confirmPublish"))
+ )
+ return;
+ await run(async () => {
+ const result = await publishCatalogPackage({
+ id: saved.id,
+ version: saved.version,
+ fingerprint: review.fingerprint,
+ });
+ if (!result.ok) {
+ setReview(null);
+ throw new Error(result.error);
+ }
+ setPublication(result.data);
+ setReview(null);
+ setNotice(
+ t("publishedSuccess", {
+ pages: result.data.pageIds.length,
+ offers: result.data.offerCount,
+ }),
+ );
+ const current = await getCatalogPackage(saved.id).catch(() => null);
+ if (current?.ok) {
+ setSaved(current.data);
+ setDraft(current.data.draft);
+ }
+ await refreshList();
+ });
+ }
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/features/catalog/components/packages/package-bulk-prices.tsx b/src/features/catalog/components/packages/package-bulk-prices.tsx
new file mode 100644
index 0000000000..75f522d0a1
--- /dev/null
+++ b/src/features/catalog/components/packages/package-bulk-prices.tsx
@@ -0,0 +1,162 @@
+"use client";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import type { PackageOffer } from "@/features/catalog/domain/packages";
+import { packageBulkPrices } from "./package-editor-helpers";
+
+export function PackageBulkPrices({
+ offers,
+ ids,
+ onApply,
+}: {
+ offers: PackageOffer[];
+ ids: number[];
+ onApply: (offers: PackageOffer[]) => void;
+}) {
+ const t = useTranslations("pages.admin.catalog.packages");
+ const [field, setField] = useState<"costCredits" | "costPoints">(
+ "costCredits",
+ );
+ const [mode, setMode] = useState<"set" | "add" | "percent">("set");
+ const [value, setValue] = useState("0");
+ const [preview, setPreview] = useState | null>(null);
+ const [error, setError] = useState("");
+ const [page, setPage] = useState(0);
+ function clear() {
+ setPreview(null);
+ setError("");
+ setPage(0);
+ }
+ return (
+
+ {t("bulkPrices", { count: ids.length })}
+ {t("bulkHint")}
+
+
+
+
+
+
+ {error && {error}
}
+ {preview && (
+
+
+
+
+ | {t("offer")} |
+ {t("before")} |
+ {t("after")} |
+
+
+
+ {preview.slice(page * 20, (page + 1) * 20).map((row) => (
+
+ |
+ #{row.before.id} {row.before.catalogName}
+ |
+ {row.before[field]} |
+ {row.after[field]} |
+
+ ))}
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/features/catalog/components/packages/package-category-picker.tsx b/src/features/catalog/components/packages/package-category-picker.tsx
new file mode 100644
index 0000000000..4a1e375662
--- /dev/null
+++ b/src/features/catalog/components/packages/package-category-picker.tsx
@@ -0,0 +1,109 @@
+"use client";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+export function PackageCategoryPicker({
+ pages,
+ selected,
+ onChange,
+ multiple = false,
+ root = false,
+}: {
+ pages: Array<{ id: number; caption: string }>;
+ selected: number[];
+ onChange: (ids: number[]) => void;
+ multiple?: boolean;
+ root?: boolean;
+}) {
+ const t = useTranslations("pages.admin.catalog.packages");
+ const [query, setQuery] = useState("");
+ const [page, setPage] = useState(0);
+ const matches = useMemo(
+ () =>
+ pages.filter((row) =>
+ `${row.id} ${row.caption}`.toLowerCase().includes(query.toLowerCase()),
+ ),
+ [pages, query],
+ );
+ return (
+
+
+ {root && (
+
+ )}
+
+
+
+
+
+ {t("selectedCategories", { count: selected.length })}
+
+ {multiple && selected.length > 0 && (
+
+ )}
+
+
+ );
+}
diff --git a/src/features/catalog/components/packages/package-editor-components.test.tsx b/src/features/catalog/components/packages/package-editor-components.test.tsx
new file mode 100644
index 0000000000..7c691c61fc
--- /dev/null
+++ b/src/features/catalog/components/packages/package-editor-components.test.tsx
@@ -0,0 +1,70 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { expect, it, vi } from "vitest";
+import type { PackageSnapshot } from "@/features/catalog/domain/packages";
+
+vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
+
+import { PackageCategoryPicker } from "./package-category-picker";
+import { PackageSnapshotEditor } from "./package-snapshot-editor";
+
+it("renders a bounded category page from a large destination list", () => {
+ const pages = Array.from({ length: 5000 }, (_, index) => ({
+ id: index + 1,
+ caption: `Category ${index + 1}`,
+ }));
+ const markup = renderToStaticMarkup(
+ {}}
+ multiple
+ />,
+ );
+ expect((markup.match(/type="checkbox"/g) ?? []).length).toBe(25);
+ expect(markup).toContain("Category 25");
+ expect(markup).not.toContain("Category 26");
+});
+it("bounds offer controls and disables editing in a published or read-only snapshot", () => {
+ const snapshot: PackageSnapshot = {
+ pages: [
+ {
+ id: 10,
+ parentId: -1,
+ caption: "Rooms",
+ enabled: "1",
+ visible: "1",
+ iconImage: 1,
+ minRank: 1,
+ clubOnly: "0",
+ vipOnly: "0",
+ orderNum: 1,
+ pageLayout: "default_3x3",
+ },
+ ],
+ offers: Array.from({ length: 500 }, (_, index) => ({
+ id: index + 1,
+ pageId: 10,
+ catalogName: `Offer ${index + 1}`,
+ itemIds: "42",
+ costCredits: 100,
+ costPoints: 0,
+ pointsType: 0,
+ amount: 1,
+ })),
+ };
+ const markup = renderToStaticMarkup(
+ {}}
+ />,
+ );
+ expect(markup).toContain("Offer 25");
+ expect(markup).not.toContain("Offer 26");
+ expect(markup).toContain('