From 047cd9f3ddfb4acdd1a2675ca3b92b296b89a3a9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 9 Sep 2026 20:04:10 +0200 Subject: [PATCH] feat(catalog): add saved packages, preview and bulk price editing --- docs/cms-upgrade-2026-09.md | 35 + drizzle/migrations/0027_catalog_packages.sql | 10 + src/actions/catalog-packages.test.ts | 156 +++++ src/actions/catalog-packages.ts | 123 ++++ src/actions/catalog-preview-assets.test.ts | 46 ++ src/actions/catalog-preview-assets.ts | 31 + src/app/admin/catalog/page.tsx | 2 + src/db/catalog-packages.ts | 16 + .../catalog-package-preview-model.test.ts | 164 +++++ .../packages/catalog-package-preview-model.ts | 153 +++++ .../packages/catalog-package-preview.tsx | 483 +++++++++++++ .../packages/catalog-packages-dialog.tsx | 632 ++++++++++++++++++ .../packages/package-bulk-prices.tsx | 162 +++++ .../packages/package-category-picker.tsx | 109 +++ .../package-editor-components.test.tsx | 70 ++ .../packages/package-editor-helpers.test.ts | 127 ++++ .../packages/package-editor-helpers.ts | 103 +++ .../packages/package-snapshot-editor.tsx | 325 +++++++++ src/features/catalog/domain/packages.ts | 146 ++++ .../catalog/server/package-messages.ts | 30 + .../catalog/server/package-snapshot.ts | 163 +++++ src/features/catalog/server/packages.test.ts | 367 ++++++++++ src/features/catalog/server/packages.ts | 428 ++++++++++++ src/messages/ar.json | 155 +++++ src/messages/bg.json | 153 +++++ src/messages/cs.json | 153 +++++ src/messages/da.json | 153 +++++ src/messages/de.json | 153 +++++ src/messages/el.json | 153 +++++ src/messages/en.json | 153 +++++ src/messages/es.json | 153 +++++ src/messages/fi.json | 155 +++++ src/messages/fr.json | 153 +++++ src/messages/hr.json | 153 +++++ src/messages/hu.json | 153 +++++ src/messages/it.json | 153 +++++ src/messages/ja.json | 155 +++++ src/messages/nl.json | 153 +++++ src/messages/no.json | 153 +++++ src/messages/pl.json | 153 +++++ src/messages/pt.json | 153 +++++ src/messages/ro.json | 153 +++++ src/messages/ru.json | 153 +++++ src/messages/sk.json | 153 +++++ src/messages/sr.json | 153 +++++ src/messages/sv.json | 153 +++++ src/messages/tr.json | 153 +++++ src/messages/uk.json | 153 +++++ 48 files changed, 7712 insertions(+) create mode 100644 drizzle/migrations/0027_catalog_packages.sql create mode 100644 src/actions/catalog-packages.test.ts create mode 100644 src/actions/catalog-packages.ts create mode 100644 src/actions/catalog-preview-assets.test.ts create mode 100644 src/actions/catalog-preview-assets.ts create mode 100644 src/db/catalog-packages.ts create mode 100644 src/features/catalog/components/packages/catalog-package-preview-model.test.ts create mode 100644 src/features/catalog/components/packages/catalog-package-preview-model.ts create mode 100644 src/features/catalog/components/packages/catalog-package-preview.tsx create mode 100644 src/features/catalog/components/packages/catalog-packages-dialog.tsx create mode 100644 src/features/catalog/components/packages/package-bulk-prices.tsx create mode 100644 src/features/catalog/components/packages/package-category-picker.tsx create mode 100644 src/features/catalog/components/packages/package-editor-components.test.tsx create mode 100644 src/features/catalog/components/packages/package-editor-helpers.test.ts create mode 100644 src/features/catalog/components/packages/package-editor-helpers.ts create mode 100644 src/features/catalog/components/packages/package-snapshot-editor.tsx create mode 100644 src/features/catalog/domain/packages.ts create mode 100644 src/features/catalog/server/package-messages.ts create mode 100644 src/features/catalog/server/package-snapshot.ts create mode 100644 src/features/catalog/server/packages.test.ts create mode 100644 src/features/catalog/server/packages.ts diff --git a/docs/cms-upgrade-2026-09.md b/docs/cms-upgrade-2026-09.md index 21234e32..7491accc 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 00000000..8fc53b26 --- /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 00000000..6bd662a4 --- /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 00000000..6ad2d6a1 --- /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 00000000..a379305e --- /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 00000000..18d291d6 --- /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 c63be761..d739b090 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 00000000..8f703d4e --- /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 00000000..75c4da78 --- /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 00000000..69764b2c --- /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 ( + <> + + { + if (next || mayLeave()) { + setOpen(next); + if (!next) { + setSaved(null); + setDraft(null); + setCreating(false); + } + } + }} + > + + + {t("title")} + {t("description")} + + {error && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} + {busy && ( +

+ {t("working")} +

+ )} +
+ +
+ {creating && ( +
+

{t("createPackage")}

+ + +

+ {t(mode === "copy" ? "copyHint" : "updateHint")} +

+

+ {t("sourceCategories")} +

+

+ {t("packageLimits")} +

+ + {mode === "copy" && ( +
+

+ {t("targetParent")} +

+ setTarget(ids[0])} + root + /> +
+ )} + +
+ )} + {!creating && !saved && ( +

{t("choosePackage")}

+ )} + {saved && draft && ( + <> +
+ + {t(saved.status)} · v{saved.version} + + {t(saved.mode)} + {dirty && {t("unsaved")}} +
+
+ + {saved.mode === "copy" && ( +
+ + {t("targetParent")}:{" "} + {categories.find((page) => page.id === target) + ?.caption ?? + (target === -1 ? t("rootCategory") : `#${target}`)} + + { + setTarget(ids[0]); + invalidate(); + }} + root + /> +
+ )} +
+
+ + + +
+ {tab === "edit" && ( + { + setDraft(value); + invalidate(); + }} + /> + )} + {tab === "preview" && ( +
+ + +
+ )} + {tab === "review" && ( +
+

{t("changes")}

+

+ {t( + saved.mode === "copy" ? "copyReview" : "updateReview", + )} +

+
+ + + + + + + + + + + {changes + .slice(reviewPage * 20, (reviewPage + 1) * 20) + .map((change) => ( + + + + + + + ))} + +
{t("entry")}{t("field")}{t("before")}{t("after")}
+ {t(change.kind)} #{change.id} {change.label} + {t(change.field)} + {change.before} + + {change.after} +
+
+ {!changes.length && ( +

{t("noChanges")}

+ )} +
+ + +
+ {review && ( +
+

+ {t("publicationImpact", { + pages: review.changedPages, + offers: review.changedOffers, + })} +

+ {review.conflicts.length ? ( + <> +

{t("conflicts")}

+
    + {[...new Set(review.conflicts)] + .slice(0, 50) + .map((conflict) => ( +
  • {conflict}
  • + ))} +
+ + ) : ( +

{t("readyToPublish")}

+ )} +
+ )} +
+ )} + {publication?.warnings?.map((warning) => ( +

+ {warning} +

+ ))} + {editable && ( +
+ + + {review && !review.conflicts.length && ( + + )} +

+ {t("saveHint")} +

+
+ )} + + )} +
+
+
+
+ + ); +} 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 00000000..75f522d0 --- /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 && ( +
+ + + + + + + + + + {preview.slice(page * 20, (page + 1) * 20).map((row) => ( + + + + + + ))} + +
{t("offer")}{t("before")}{t("after")}
+ #{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 00000000..4a1e3756 --- /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 && ( + + )} +
    + {matches.slice(page * 25, (page + 1) * 25).map((row) => ( +
  • + +
  • + ))} +
+
+ + + + {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 00000000..7c691c61 --- /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('
{ + const rows = [offer, { ...offer, id: 2, costCredits: 200 }]; + const result = packageBulkPrices(rows, [1], { + costCredits: { mode: "percent", value: -25 }, + }); + expect(result).toEqual([ + { before: offer, after: { ...offer, costCredits: 75 } }, + ]); + expect(rows[0].costCredits).toBe(100); + expect(rows[1].costCredits).toBe(200); + expect(result[0].after.itemIds).toBe("42"); + expect(result[0].after.pageId).toBe(10); +}); +it("supports absolute and additive prices, rejecting invalid selection and negative results", () => { + expect( + packageBulkPrices([offer], [1], { + costPoints: { mode: "set", value: 7 }, + })[0].after.costPoints, + ).toBe(7); + expect( + packageBulkPrices([offer], [1], { + costCredits: { mode: "add", value: 5 }, + })[0].after.costCredits, + ).toBe(105); + expect(() => + packageBulkPrices([offer], [1], { + costCredits: { mode: "add", value: -101 }, + }), + ).toThrow(); + expect(() => + packageBulkPrices([offer], [2], { costCredits: { mode: "set", value: 1 } }), + ).toThrow(); + expect(() => + packageBulkPrices([offer], [], { costCredits: { mode: "set", value: 1 } }), + ).toThrow(); + expect(() => + packageBulkPrices([offer], [1, 1], { + costCredits: { mode: "set", value: 1 }, + }), + ).toThrow(); +}); +it("binds publication eligibility to the saved version and rejects dirty or conflicting reviews", () => { + const saved = { status: "draft" as const, version: 3 }; + const review = { + version: 3, + fingerprint: "a".repeat(64), + changedPages: 1, + changedOffers: 1, + conflicts: [], + }; + expect(packagePublicationIsCurrent(saved, review, false)).toBe(true); + expect( + packagePublicationIsCurrent(saved, { ...review, version: 4 }, false), + ).toBe(false); + expect(packagePublicationIsCurrent(saved, review, true)).toBe(false); + expect( + packagePublicationIsCurrent( + saved, + { ...review, conflicts: ["Changed live data"] }, + false, + ), + ).toBe(false); + expect( + packagePublicationIsCurrent( + { ...saved, status: "published" }, + review, + false, + ), + ).toBe(false); +}); +it("shows precise category and offer changes against the original snapshot", () => { + const original: 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: [offer], + }; + const draft: PackageSnapshot = { + pages: [{ ...original.pages[0], caption: "Chairs", visible: "0" }], + offers: [{ ...offer, costCredits: 50 }], + }; + expect( + packageChanges(original, draft).map((row) => [ + row.field, + row.before, + row.after, + ]), + ).toEqual([ + ["caption", "Rooms", "Chairs"], + ["visible", "1", "0"], + ["costCredits", "100", "50"], + ]); + expect(packageChanges(original, original)).toEqual([]); +}); diff --git a/src/features/catalog/components/packages/package-editor-helpers.ts b/src/features/catalog/components/packages/package-editor-helpers.ts new file mode 100644 index 00000000..3b3e41ca --- /dev/null +++ b/src/features/catalog/components/packages/package-editor-helpers.ts @@ -0,0 +1,103 @@ +import { + type BulkOfferInput, + projectBulkOffer, +} from "@/features/catalog/domain/bulk-offers"; +import type { + CatalogPackage, + PackageOffer, + PackagePreview, + PackageSnapshot, +} from "@/features/catalog/domain/packages"; + +export function packageBulkPrices( + offers: PackageOffer[], + ids: number[], + changes: BulkOfferInput["changes"], +) { + if (!ids.length || ids.length > 500 || new Set(ids).size !== ids.length) + throw new Error("invalidSelection"); + const chosen = new Set(ids); + const rows = offers.filter((offer) => chosen.has(offer.id)); + if (rows.length !== ids.length) throw new Error("invalidSelection"); + return rows.map((before) => ({ + before, + after: { ...before, ...projectBulkOffer(before, changes) }, + })); +} +export function packagePublicationIsCurrent( + saved: Pick, + preview: PackagePreview, + dirty: boolean, +) { + return ( + !dirty && + saved.status === "draft" && + preview.version === saved.version && + preview.conflicts.length === 0 + ); +} +const pageFields = [ + "caption", + "enabled", + "visible", + "iconImage", + "minRank", + "clubOnly", + "vipOnly", + "orderNum", + "pageLayout", +] as const; +const offerFields = [ + "catalogName", + "costCredits", + "costPoints", + "pointsType", + "amount", +] as const; +export function packageChanges( + baseline: PackageSnapshot, + draft: PackageSnapshot, +) { + const rows: Array<{ + key: string; + kind: "category" | "offer"; + id: number; + label: string; + field: string; + before: string; + after: string; + }> = []; + const pages = new Map(baseline.pages.map((page) => [page.id, page])); + const offers = new Map(baseline.offers.map((offer) => [offer.id, offer])); + for (const page of draft.pages) { + const original = pages.get(page.id); + if (!original) continue; + for (const field of pageFields) + if (original[field] !== page[field]) + rows.push({ + key: `page:${page.id}:${field}`, + kind: "category", + id: page.id, + label: page.caption, + field, + before: String(original[field]), + after: String(page[field]), + }); + } + for (const offer of draft.offers) { + const original = offers.get(offer.id); + if (!original) continue; + for (const field of offerFields) + if (original[field] !== offer[field]) + rows.push({ + key: `offer:${offer.id}:${field}`, + kind: "offer", + id: offer.id, + label: offer.catalogName, + field, + before: String(original[field]), + after: String(offer[field]), + }); + } + return rows; +} diff --git a/src/features/catalog/components/packages/package-snapshot-editor.tsx b/src/features/catalog/components/packages/package-snapshot-editor.tsx new file mode 100644 index 00000000..4ab737d3 --- /dev/null +++ b/src/features/catalog/components/packages/package-snapshot-editor.tsx @@ -0,0 +1,325 @@ +"use client"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import type { + PackagePage, + PackageSnapshot, +} from "@/features/catalog/domain/packages"; +import { PackageBulkPrices } from "./package-bulk-prices"; + +export function PackageSnapshotEditor({ + snapshot, + disabled, + revision, + onChange, +}: { + snapshot: PackageSnapshot; + disabled: boolean; + revision: number; + onChange: (snapshot: PackageSnapshot) => void; +}) { + const t = useTranslations("pages.admin.catalog.packages"); + const [selectedPage, setSelectedPage] = useState(snapshot.pages[0]?.id); + const [pageQuery, setPageQuery] = useState(""); + const [categoryPage, setCategoryPage] = useState(0); + const [offerPage, setOfferPage] = useState(0); + const [offerQuery, setOfferQuery] = useState(""); + const [onlyCategory, setOnlyCategory] = useState(true); + const [selectedOffers, setSelectedOffers] = useState([]); + const page = snapshot.pages.find((row) => row.id === selectedPage); + const categories = snapshot.pages.filter((row) => + `${row.id} ${row.caption}`.toLowerCase().includes(pageQuery.toLowerCase()), + ); + const offers = snapshot.offers.filter( + (row) => + (!onlyCategory || row.pageId === selectedPage) && + `${row.id} ${row.catalogName}` + .toLowerCase() + .includes(offerQuery.toLowerCase()), + ); + const numeric = (value: number) => (Number.isFinite(value) ? value : ""); + function changePage(patch: Partial) { + onChange({ + ...snapshot, + pages: snapshot.pages.map((row) => + row.id === selectedPage ? { ...row, ...patch } : row, + ), + }); + } + return ( +
+
+
+ +
    + {categories + .slice(categoryPage * 25, (categoryPage + 1) * 25) + .map((row) => ( +
  • + +
  • + ))} +
+
+ + +
+
+ {page && ( +
+ + {t("category")} #{page.id} + + +
+ {(["iconImage", "minRank", "orderNum"] as const).map((field) => ( + + ))} + +
+
+ {(["enabled", "visible", "clubOnly", "vipOnly"] as const).map( + (field) => ( + + ), + )} +
+
+ )} +
+
+

+ {t("offers", { count: offers.length })} +

+
+ + +
+ {!disabled && ( +
+ + + + {t("selectedOffers", { count: selectedOffers.length })} + +
+ )} +
+ + + + {!disabled && } + + {( + ["costCredits", "costPoints", "pointsType", "amount"] as const + ).map((field) => ( + + ))} + + + + {offers + .slice(offerPage * 25, (offerPage + 1) * 25) + .map((offer) => ( + + {!disabled && ( + + )} + + {( + [ + "costCredits", + "costPoints", + "pointsType", + "amount", + ] as const + ).map((field) => ( + + ))} + + ))} + +
{t("select")}{t("offer")}{t(field)}
+ + setSelectedOffers((current) => + event.target.checked + ? [...current, offer.id] + : current.filter((id) => id !== offer.id), + ) + } + /> + + #{offer.id} {offer.catalogName} + + + onChange({ + ...snapshot, + offers: snapshot.offers.map((row) => + row.id === offer.id + ? { + ...row, + [field]: event.target.valueAsNumber, + } + : row, + ), + }) + } + /> +
+
+ {!offers.length &&

{t("noOffers")}

} +
+ + +
+ {!disabled && selectedOffers.length > 0 && ( + onChange({ ...snapshot, offers })} + /> + )} +
+
+ ); +} diff --git a/src/features/catalog/domain/packages.ts b/src/features/catalog/domain/packages.ts new file mode 100644 index 00000000..25657fe4 --- /dev/null +++ b/src/features/catalog/domain/packages.ts @@ -0,0 +1,146 @@ +import { z } from "zod"; +import { CatalogInputError } from "./hierarchy"; +import { offerIdSchema, offerInteger, parseFurnitureIds } from "./offer-input"; + +const flag = z.enum(["0", "1"]); +export const packagePageSchema = z + .object({ + id: offerIdSchema, + parentId: offerInteger.min(-1), + caption: z.string().min(1).max(128), + enabled: flag, + visible: flag, + iconImage: offerInteger, + minRank: offerInteger, + clubOnly: flag, + vipOnly: flag, + orderNum: offerInteger, + pageLayout: z.string().min(1).max(26), + }) + .strict(); +export const packageOfferSchema = z + .object({ + id: offerIdSchema, + pageId: offerIdSchema, + catalogName: z.string().max(100), + clubOnly: flag.optional(), + itemIds: z + .string() + .max(666) + .refine((value) => { + try { + parseFurnitureIds(value); + return true; + } catch { + return false; + } + }), + costCredits: offerInteger, + costPoints: offerInteger, + pointsType: offerInteger, + amount: offerInteger, + }) + .strict(); +export const packageSnapshotSchema = z + .object({ + pages: z.array(packagePageSchema).min(1).max(200), + offers: z.array(packageOfferSchema).max(500), + }) + .strict(); +export type PackagePage = z.infer; +export type PackageOffer = z.infer; +export type PackageSnapshot = z.infer; +export interface CatalogPackage { + id: string; + name: string; + version: number; + status: "draft" | "published"; + mode: "update" | "copy"; + targetParentId: number; + baseline: PackageSnapshot; + draft: PackageSnapshot; + updatedAt: string; + publishedAt: string | null; +} +export type PackageListRow = Pick< + CatalogPackage, + "id" | "name" | "version" | "status" | "mode" | "updatedAt" +>; +export interface PackagePreview { + version: number; + fingerprint: string; + changedPages: number; + changedOffers: number; + conflicts: string[]; +} +export interface PackagePublication { + pageIds: number[]; + offerCount: number; + alreadyPublished: boolean; + warnings?: string[]; +} +export const packageIdSchema = z.uuid(); +export const packageParentSchema = offerInteger.refine( + (value) => value === -1 || value > 0, +); +export const createPackageSchema = z + .object({ + name: z.string().trim().min(1).max(128), + pageIds: z.array(offerIdSchema).min(1).max(200), + mode: z.enum(["update", "copy"]), + targetParentId: packageParentSchema, + }) + .strict(); +export type CreateCatalogPackageInput = z.infer; +export const savePackageSchema = z + .object({ + id: packageIdSchema, + version: z.number().int().positive(), + name: z.string().trim().min(1).max(128), + draft: packageSnapshotSchema, + targetParentId: packageParentSchema, + }) + .strict(); +export type SaveCatalogPackageInput = z.infer; +export const publishPackageSchema = z + .object({ + id: packageIdSchema, + version: z.number().int().positive(), + fingerprint: z.string().regex(/^[a-f0-9]{64}$/), + }) + .strict(); +export type PublishCatalogPackageInput = z.infer; +/** Editing changes metadata and commercial fields, never package membership or furniture references. */ +export function validatePackageDraft( + baseline: PackageSnapshot, + value: unknown, +): PackageSnapshot { + const draft = packageSnapshotSchema.parse(value); + for (const kind of ["pages", "offers"] as const) { + if ( + draft[kind].length !== baseline[kind].length || + new Set(draft[kind].map((row) => row.id)).size !== draft[kind].length + ) + throw new CatalogInputError("Package membership cannot change"); + } + const pages = new Map(baseline.pages.map((page) => [page.id, page])); + const offers = new Map(baseline.offers.map((offer) => [offer.id, offer])); + for (const page of draft.pages) + if (!pages.has(page.id) || pages.get(page.id)?.parentId !== page.parentId) + throw new CatalogInputError("Package hierarchy cannot change"); + for (const offer of draft.offers) { + const source = offers.get(offer.id); + if ( + !source || + source.pageId !== offer.pageId || + source.itemIds !== offer.itemIds || + source.clubOnly !== offer.clubOnly || + !pages.has(offer.pageId) + ) + throw new CatalogInputError("Package furniture references cannot change"); + } + return { + pages: [...draft.pages].sort((a, b) => a.id - b.id), + offers: [...draft.offers].sort((a, b) => a.id - b.id), + }; +} diff --git a/src/features/catalog/server/package-messages.ts b/src/features/catalog/server/package-messages.ts new file mode 100644 index 00000000..896f0380 --- /dev/null +++ b/src/features/catalog/server/package-messages.ts @@ -0,0 +1,30 @@ +import "server-only"; +import { getTranslations } from "next-intl/server"; + +const messages: Record = { + "Source categories or offers changed since this package was created. Create a fresh package to include the current data.": + "sourceChanged", + "Package changed. Reload it before saving": "saveConflict", + "Package changed. Refresh the publication preview": "publishConflict", + "Catalog changed. Refresh the publication preview": "catalogChanged", + "Catalog package not found": "missing", + "A package supports at most 200 categories including descendants": + "pageLimit", + "A package supports at most 500 offers": "offerLimit", + "Package data exceeds 8 MB safety limit": "payloadLimit", + "Package membership cannot change": "membership", + "Package hierarchy cannot change": "hierarchy", + "Package furniture references cannot change": "references", + "Package furniture reference no longer exists": "furnitureMissing", + "Package published, but audit logging failed.": "auditWarning", + "Package published, but the hotel update was not delivered.": "hotelWarning", + "Package published, but export scheduling failed.": "exportWarning", + "Package published. Refresh the page to see the latest catalog.": + "refreshWarning", +}; +export async function packageMessage(message: string): Promise { + const key = messages[message]; + if (!key) return message; + const t = await getTranslations("pages.admin.catalogPackages.backend"); + return t(key); +} diff --git a/src/features/catalog/server/package-snapshot.ts b/src/features/catalog/server/package-snapshot.ts new file mode 100644 index 00000000..be590042 --- /dev/null +++ b/src/features/catalog/server/package-snapshot.ts @@ -0,0 +1,163 @@ +import "server-only"; +import { createHash } from "node:crypto"; +import { sql } from "drizzle-orm"; +import type { db } from "@/lib/db"; +import { + assertParent, + CatalogInputError, + CatalogNotFound, +} from "../domain/hierarchy"; +import { parseFurnitureIds } from "../domain/offer-input"; +import type { PackageSnapshot } from "../domain/packages"; +import { packageSnapshotSchema } from "../domain/packages"; +export type PackageTx = Parameters[0]>[0]; +export type SourceRow = Record & { id: number }; +export interface PackageSource { + pages: SourceRow[]; + offers: SourceRow[]; +} +export function packageHash(value: unknown): string { + const canonical = (v: unknown): unknown => { + if (Array.isArray(v)) return v.map(canonical); + if (v && typeof v === "object") + return Object.fromEntries( + Object.entries(v) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, val]) => [k, canonical(val)]), + ); + return v; + }; + return createHash("sha256") + .update(JSON.stringify(canonical(JSON.parse(JSON.stringify(value))))) + .digest("hex"); +} +export const pageColumns = { + parentId: "parent_id", + caption: "caption", + enabled: "enabled", + visible: "visible", + iconImage: "icon_image", + minRank: "min_rank", + clubOnly: "club_only", + vipOnly: "vip_only", + orderNum: "order_num", + pageLayout: "page_layout", +} as const; +export const offerColumns = { + pageId: "page_id", + catalogName: "catalog_name", + itemIds: "item_ids", + costCredits: "cost_credits", + costPoints: "cost_points", + pointsType: "points_type", + amount: "amount", +} as const; +export function publicSnapshot(source: PackageSource): PackageSnapshot { + return packageSnapshotSchema.parse({ + pages: source.pages.map((p) => ({ + id: Number(p.id), + parentId: Number(p.parent_id), + caption: String(p.caption), + enabled: String(p.enabled), + visible: String(p.visible), + iconImage: Number(p.icon_image), + minRank: Number(p.min_rank), + clubOnly: String(p.club_only), + vipOnly: String(p.vip_only), + orderNum: Number(p.order_num), + pageLayout: String(p.page_layout), + })), + offers: source.offers.map((o) => ({ + id: Number(o.id), + pageId: Number(o.page_id), + catalogName: String(o.catalog_name), + clubOnly: String(o.club_only ?? "0"), + itemIds: String(o.item_ids), + costCredits: Number(o.cost_credits), + costPoints: Number(o.cost_points), + pointsType: Number(o.points_type), + amount: Number(o.amount), + })), + }); +} +export async function readPackagePages( + tx: PackageTx, + lock: boolean, +): Promise { + const [rows] = await tx.execute( + sql`SELECT * FROM catalog_pages ORDER BY id LIMIT 20001${lock ? sql` FOR UPDATE` : sql``}`, + ); + const pages = rows as unknown as SourceRow[]; + if (pages.length > 20000) + throw new CatalogInputError( + "Catalog exceeds package safety limit (20000 categories)", + ); + return pages; +} +export async function readPackageSource( + tx: PackageTx, + pages: SourceRow[], + rootIds: number[], + lock: boolean, +): Promise { + const hierarchy = pages.map((p) => ({ + id: Number(p.id), + parentId: Number(p.parent_id), + })); + const selected = new Set(rootIds); + for (const id of rootIds) { + const page = hierarchy.find((p) => p.id === id); + if (!page) + throw new CatalogNotFound( + `Package source category #${id} no longer exists`, + ); + assertParent(hierarchy, id, page.parentId); + } + let changed = true; + while (changed) { + changed = false; + for (const page of hierarchy) + if (selected.has(page.parentId) && !selected.has(page.id)) { + selected.add(page.id); + changed = true; + } + if (selected.size > 200) + throw new CatalogInputError( + "A package supports at most 200 categories including descendants", + ); + } + const selectedPages = pages.filter((p) => selected.has(Number(p.id))); + const ids = selectedPages.map((p) => String(p.id)); + const [rows] = await tx.execute( + sql`SELECT * FROM catalog_items WHERE page_id IN (${sql.join(ids, sql`, `)}) ORDER BY id LIMIT 501${lock ? sql` FOR UPDATE` : sql``}`, + ); + const offers = rows as unknown as SourceRow[]; + if (offers.length > 500) + throw new CatalogInputError("A package supports at most 500 offers"); + const furnitureIds = [ + ...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))), + ].sort((a, b) => a - b); + if (furnitureIds.length > 5000) + throw new CatalogInputError( + "Package furniture references exceed safety limit", + ); + if (furnitureIds.length) { + const [foundRows] = await tx.execute( + sql`SELECT id FROM items_base WHERE id IN (${sql.join(furnitureIds, sql`, `)}) ORDER BY id${lock ? sql` FOR UPDATE` : sql``}`, + ); + const found = new Set( + (foundRows as unknown as { id: number }[]).map((row) => Number(row.id)), + ); + if (furnitureIds.some((id) => !found.has(id))) + throw new CatalogNotFound("Package furniture reference no longer exists"); + } + return { pages: selectedPages, offers }; +} +export function columns( + row: Record, + mapping: Record, +): Record { + return Object.fromEntries( + Object.entries(mapping).map(([key, column]) => [column, row[key]]), + ); +} diff --git a/src/features/catalog/server/packages.test.ts b/src/features/catalog/server/packages.test.ts new file mode 100644 index 00000000..57c40087 --- /dev/null +++ b/src/features/catalog/server/packages.test.ts @@ -0,0 +1,367 @@ +import type { SQL } from "drizzle-orm"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + pages: [] as Record[], + offers: [] as Record[], + stored: {} as Record, + queries: [] as string[], + liveWrites: 0, + failAt: 0, + duplicateAt: 0, + nextPage: 200, + nextOffer: 500, + bases: [8], +})); +vi.mock("@/lib/services/furni-import", () => ({ + allocateCatalogItemId: async (fn: (id: number) => unknown) => + fn(state.nextOffer++), +})); +vi.mock("@/lib/db", () => ({ + db: { + transaction: async (fn: (tx: unknown) => Promise) => { + const before = structuredClone({ + pages: state.pages, + offers: state.offers, + stored: state.stored, + }); + try { + return await fn({ + execute: async (q: SQL) => { + const { sql, params } = new MySqlDialect().sqlToQuery(q); + state.queries.push(sql); + if (sql.startsWith("SELECT")) { + if (sql.includes("website_catalog_packages")) + return [ + [ + ...(state.stored[String(params[0])] + ? [{ payload: state.stored[String(params[0])] }] + : []), + ], + [], + ]; + if (sql.includes("FROM catalog_pages")) + return [structuredClone(state.pages), []]; + if (sql.includes("FROM catalog_items")) + return [ + structuredClone( + state.offers.filter((o) => + params.map(String).includes(String(o.page_id)), + ), + ), + [], + ]; + if (sql.includes("items_base")) + return [state.bases.map((id) => ({ id })), []]; + } + if (sql.startsWith("INSERT INTO website_catalog_packages")) { + state.stored[String(params[0])] = String(params[5]); + return [{}, []]; + } + if (sql.startsWith("UPDATE website_catalog_packages")) { + state.stored[String(params[4])] = String(params[3]); + return [{}, []]; + } + if (sql.startsWith("INSERT") || sql.startsWith("UPDATE")) { + state.liveWrites++; + if (state.duplicateAt === state.liveWrites) + throw Object.assign(Error("reserved ID collision"), { + errno: 1062, + }); + if (state.failAt === state.liveWrites) + throw Error("simulated SQL failure"); + const isPage = sql.includes("`catalog_pages`"), + rows = isPage ? state.pages : state.offers; + const keys = [...sql.matchAll(/`([^`]+)`=/g)].map((m) => m[1]); + const fields = Object.fromEntries( + keys.map((key, index) => [key, params[index]]), + ); + if (sql.startsWith("INSERT")) { + const id = Number(fields.id ?? state.nextPage++); + rows.push({ ...fields, id }); + return [{ insertId: id }, []]; + } + Object.assign( + rows.find((row) => Number(row.id) === Number(params.at(-1))) ?? + {}, + fields, + ); + return [{}, []]; + } + throw Error(`Unexpected query ${sql}`); + }, + }); + } catch (error) { + Object.assign(state, before); + throw error; + } + }, + }, +})); + +import { validatePackageDraft } from "../domain/packages"; +import { + createCatalogPackageCommand, + getCatalogPackageCommand, + previewCatalogPackageCommand, + publishCatalogPackageCommand, + saveCatalogPackageCommand, +} from "./packages"; + +function page(id: number, parent_id: number) { + return { + id, + parent_id, + caption: `Page ${id}`, + enabled: "1", + visible: "1", + icon_image: 1, + min_rank: 1, + club_only: "0", + vip_only: "0", + order_num: 1, + page_layout: "default_3x3", + includes: "", + page_text1: "Keep source text", + page_headline: "header", + caption_save: "saved", + }; +} +beforeEach(() => { + state.pages = [page(1, -1), page(2, 1), page(3, -1)]; + state.offers = [ + { + id: 10, + page_id: "2", + catalog_name: "Chair", + item_ids: "8", + cost_credits: 3, + cost_points: 0, + points_type: 0, + amount: 1, + limited_stack: 40, + limited_sells: 7, + extradata: "preserve", + offer_id: 10, + }, + ]; + state.stored = {}; + state.queries = []; + state.liveWrites = 0; + state.failAt = 0; + state.duplicateAt = 0; + state.nextPage = 200; + state.nextOffer = 500; + state.bases = [8]; +}); +const create = (mode: "update" | "copy" = "update") => + createCatalogPackageCommand({ + name: "Release", + pageIds: [1], + mode, + targetParentId: -1, + }); +async function publish(id: string, version: number) { + const preview = await previewCatalogPackageCommand(id); + return publishCatalogPackageCommand({ + id, + version, + fingerprint: preview.fingerprint, + }); +} +describe("catalog packages", () => { + it("creates a private descendant snapshot and saves drafts without changing live rows", async () => { + const p = await create(); + expect(p.baseline.pages.map((p) => p.id)).toEqual([1, 2]); + expect(p.baseline.offers).toHaveLength(1); + const saved = await saveCatalogPackageCommand({ + id: p.id, + version: p.version, + name: p.name, + targetParentId: -1, + draft: { + ...p.draft, + offers: p.draft.offers.map((o) => ({ ...o, costCredits: 25 })), + }, + }); + expect(saved.version).toBe(2); + expect(state.liveWrites).toBe(0); + expect(state.offers[0].cost_credits).toBe(3); + }); + it("applies reviewed changes atomically and makes publication retry idempotent", async () => { + const p = await create(); + p.draft.offers[0].costCredits = 25; + const saved = await saveCatalogPackageCommand({ + id: p.id, + version: 1, + name: p.name, + targetParentId: -1, + draft: p.draft, + }); + const preview = await previewCatalogPackageCommand(p.id); + expect(preview.changedOffers).toBe(1); + const input = { + id: p.id, + version: saved.version, + fingerprint: preview.fingerprint, + }; + const result = await publishCatalogPackageCommand(input); + expect(result.alreadyPublished).toBe(false); + expect(state.offers[0].cost_credits).toBe(25); + const writes = state.liveWrites; + expect((await publishCatalogPackageCommand(input)).alreadyPublished).toBe( + true, + ); + expect(state.liveWrites).toBe(writes); + expect( + state.queries.some( + (q) => q.includes("catalog_pages") && q.includes("FOR UPDATE"), + ), + ).toBe(true); + }); + it("copies untouched source columns and remaps local page and offer references", async () => { + state.pages[0].includes = "2"; + const p = await create("copy"); + const result = await publish(p.id, p.version); + expect(result.pageIds).toEqual([200, 201]); + expect(state.pages.find((p) => p.id === 200)).toMatchObject({ + parent_id: -1, + includes: "201", + page_text1: "Keep source text", + caption_save: "saved", + }); + expect(state.pages.find((p) => p.id === 201)?.parent_id).toBe(200); + expect(state.offers.find((o) => o.id === 500)).toMatchObject({ + page_id: "201", + offer_id: 500, + item_ids: "8", + limited_stack: 40, + limited_sells: 7, + extradata: "preserve", + }); + }); + it("rolls back all category and offer changes plus package status on a SQL failure", async () => { + const p = await create("copy"); + const preview = await previewCatalogPackageCommand(p.id); + state.failAt = 2; + await expect( + publishCatalogPackageCommand({ + id: p.id, + version: 1, + fingerprint: preview.fingerprint, + }), + ).rejects.toThrow("simulated SQL failure"); + expect(state.pages).toHaveLength(3); + expect(state.offers).toHaveLength(1); + expect((await getCatalogPackageCommand(p.id)).status).toBe("draft"); + }); + it.each([ + "field", + "offerAdded", + "offerMoved", + "pageAdded", + "pageMissing", + "furnitureMissing", + ])("blocks changed live state: %s", async (change) => { + const p = await create("copy"), + preview = await previewCatalogPackageCommand(p.id); + if (change === "field") state.offers[0].limited_stack = 99; + if (change === "offerAdded") + state.offers.push({ ...state.offers[0], id: 11 }); + if (change === "offerMoved") state.offers[0].page_id = "3"; + if (change === "pageAdded") state.pages.push(page(4, 1)); + if (change === "pageMissing") + state.pages = state.pages.filter((p) => p.id !== 1); + if (change === "furnitureMissing") state.bases = []; + await expect( + publishCatalogPackageCommand({ + id: p.id, + version: 1, + fingerprint: preview.fingerprint, + }), + ).rejects.toThrow(); + expect(state.liveWrites).toBe(0); + }); + it("requires a fresh preview after destination changes", async () => { + const p = await createCatalogPackageCommand({ + name: "Copy", + pageIds: [1], + mode: "copy", + targetParentId: 3, + }), + preview = await previewCatalogPackageCommand(p.id); + state.pages[2].min_rank = 99; + await expect( + publishCatalogPackageCommand({ + id: p.id, + version: 1, + fingerprint: preview.fingerprint, + }), + ).rejects.toThrow("Refresh"); + expect(state.liveWrites).toBe(0); + }); + it("rejects stale draft versions and rogue members or furniture references", async () => { + const p = await create(); + const input = { + id: p.id, + version: 1, + name: p.name, + targetParentId: -1, + draft: p.draft, + }; + await saveCatalogPackageCommand(input); + await expect(saveCatalogPackageCommand(input)).rejects.toThrow( + "Package changed", + ); + expect(() => + validatePackageDraft(p.baseline, { + ...p.draft, + offers: [{ ...p.draft.offers[0], id: 99 }], + }), + ).toThrow(); + expect(() => + validatePackageDraft(p.baseline, { + ...p.draft, + offers: [{ ...p.draft.offers[0], itemIds: "99" }], + }), + ).toThrow(); + }); + it("bounds descendants, offers and raw package payload", async () => { + state.pages = Array.from({ length: 201 }, (_, i) => + page(i + 1, i ? 1 : -1), + ); + await expect(create()).rejects.toThrow("200 categories"); + state.pages = [page(1, -1), page(2, 1)]; + state.offers = Array.from({ length: 501 }, (_, i) => ({ + ...state.offers[0], + id: i + 1, + })); + await expect(create()).rejects.toThrow("500 offers"); + state.offers = []; + state.pages[0].page_text1 = "x".repeat(8_000_001); + await expect(create()).rejects.toThrow("8 MB"); + }); +}); + +it("retries a rolled-back copy with fresh offer IDs after a concurrent allocation collision", async () => { + const p = await create("copy"); + state.duplicateAt = 3; + const result = await publish(p.id, 1); + expect(result.alreadyPublished).toBe(false); + expect(state.pages).toHaveLength(5); + expect(state.offers).toHaveLength(2); + expect(state.offers.at(-1)?.id).toBe(501); + expect((await getCatalogPackageCommand(p.id)).status).toBe("published"); +}); +it("keeps offer membership access immutable in editable drafts", async () => { + state.offers[0].club_only = "1"; + const p = await create(); + expect(p.baseline.offers[0].clubOnly).toBe("1"); + expect(() => + validatePackageDraft(p.baseline, { + ...p.draft, + offers: [{ ...p.draft.offers[0], clubOnly: "0" }], + }), + ).toThrow("references"); +}); diff --git a/src/features/catalog/server/packages.ts b/src/features/catalog/server/packages.ts new file mode 100644 index 00000000..0919884c --- /dev/null +++ b/src/features/catalog/server/packages.ts @@ -0,0 +1,428 @@ +import "server-only"; +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { allocateCatalogItemId } from "@/lib/services/furni-import"; +import { remapIncludes } from "../domain/duplicate"; +import { + assertParent, + CatalogConflict, + CatalogInputError, + CatalogNotFound, +} from "../domain/hierarchy"; +import { + type CatalogPackage, + type CreateCatalogPackageInput, + createPackageSchema, + type PackageListRow, + type PackagePreview, + type PackagePublication, + type PublishCatalogPackageInput, + packageIdSchema, + publishPackageSchema, + type SaveCatalogPackageInput, + savePackageSchema, + validatePackageDraft, +} from "../domain/packages"; +import { + columns, + offerColumns, + type PackageSource, + type PackageTx, + packageHash, + pageColumns, + publicSnapshot, + readPackagePages, + readPackageSource, + type SourceRow, +} from "./package-snapshot"; + +interface StoredPackage { + package: CatalogPackage; + roots: number[]; + source: PackageSource; + publication?: PackagePublication; +} +function encode(stored: StoredPackage) { + const value = JSON.stringify(stored); + if (Buffer.byteLength(value) > 8_000_000) + throw new CatalogInputError("Package data exceeds 8 MB safety limit"); + return value; +} +async function read( + tx: PackageTx, + id: string, + lock = false, +): Promise { + const [rows] = await tx.execute( + sql`SELECT payload FROM website_catalog_packages WHERE id=${packageIdSchema.parse(id)}${lock ? sql` FOR UPDATE` : sql``}`, + ); + const row = (rows as unknown as { payload: string }[])[0]; + if (!row) throw new CatalogNotFound("Catalog package not found"); + return JSON.parse(row.payload) as StoredPackage; +} +async function write(tx: PackageTx, stored: StoredPackage, create = false) { + const p = stored.package, + payload = encode(stored); + if (create) + await tx.execute( + sql`INSERT INTO website_catalog_packages (id,name,version,status,mode,payload,updated_at) VALUES (${p.id},${p.name},${p.version},${p.status},${p.mode},${payload},CURRENT_TIMESTAMP)`, + ); + else + await tx.execute( + sql`UPDATE website_catalog_packages SET name=${p.name}, version=${p.version}, status=${p.status}, payload=${payload}, updated_at=CURRENT_TIMESTAMP WHERE id=${p.id}`, + ); +} +export async function listCatalogPackagesCommand(): Promise { + const [rows] = await db.execute( + sql`SELECT id,name,version,status,mode,updated_at AS updatedAt FROM website_catalog_packages ORDER BY updated_at DESC,id LIMIT 100`, + ); + return ( + rows as unknown as Array< + Omit & { updatedAt: Date | string } + > + ).map((row) => ({ + ...row, + version: Number(row.version), + updatedAt: new Date(row.updatedAt).toISOString(), + })); +} +export async function getCatalogPackageCommand( + id: string, +): Promise { + return db.transaction(async (tx) => (await read(tx, id)).package); +} +export async function createCatalogPackageCommand( + raw: CreateCatalogPackageInput, +): Promise { + const input = createPackageSchema.parse(raw); + return db.transaction( + async (tx) => { + const roots = [...new Set(input.pageIds)].sort((a, b) => a - b); + const pages = await readPackagePages(tx, false); + assertParent( + pages.map((p) => ({ id: Number(p.id), parentId: Number(p.parent_id) })), + 0, + input.targetParentId, + ); + const source = await readPackageSource(tx, pages, roots, false), + baseline = publicSnapshot(source); + const stored: StoredPackage = { + package: { + id: randomUUID(), + name: input.name, + version: 1, + status: "draft", + mode: input.mode, + targetParentId: input.targetParentId, + baseline, + draft: structuredClone(baseline), + updatedAt: new Date().toISOString(), + publishedAt: null, + }, + roots, + source, + }; + await write(tx, stored, true); + return stored.package; + }, + { isolationLevel: "repeatable read" }, + ); +} +export async function saveCatalogPackageCommand( + raw: SaveCatalogPackageInput, +): Promise { + const input = savePackageSchema.parse(raw); + return db.transaction(async (tx) => { + const stored = await read(tx, input.id, true), + p = stored.package; + if (p.status !== "draft" || p.version !== input.version) + throw new CatalogConflict("Package changed. Reload it before saving"); + p.draft = validatePackageDraft(p.baseline, input.draft); + p.name = input.name; + p.targetParentId = input.targetParentId; + p.version++; + p.updatedAt = new Date().toISOString(); + await write(tx, stored); + return p; + }); +} +async function review( + tx: PackageTx, + stored: StoredPackage, + lock: boolean, +): Promise<{ + preview: PackagePreview; + source?: PackageSource; + pages: SourceRow[]; +}> { + const p = stored.package, + conflicts: string[] = []; + const pages = await readPackagePages(tx, lock); + let source: PackageSource | undefined; + try { + validatePackageDraft(p.baseline, p.draft); + source = await readPackageSource(tx, pages, stored.roots, lock); + if (packageHash(source) !== packageHash(stored.source)) + conflicts.push( + "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + ); + assertParent( + pages.map((row) => ({ + id: Number(row.id), + parentId: Number(row.parent_id), + })), + 0, + p.targetParentId, + ); + const existing = new Set(pages.map((row) => Number(row.id))); + if (p.mode === "copy") + for (const page of source.pages) + remapIncludes(page.includes, new Map(), existing); + } catch (error) { + if (error instanceof CatalogInputError || error instanceof CatalogNotFound) + conflicts.push(error.message); + else throw error; + } + const destination = pages.find( + (page) => Number(page.id) === p.targetParentId, + ); + const count = (kind: "pages" | "offers") => + p.mode === "copy" + ? p.draft[kind].length + : p.draft[kind].filter( + (row) => + packageHash(row) !== + packageHash(p.baseline[kind].find((base) => base.id === row.id)), + ).length; + return { + pages, + source, + preview: { + version: p.version, + fingerprint: packageHash({ + id: p.id, + version: p.version, + mode: p.mode, + target: p.targetParentId, + draft: p.draft, + source, + destination, + conflicts, + }), + changedPages: count("pages"), + changedOffers: count("offers"), + conflicts, + }, + }; +} +export async function previewCatalogPackageCommand( + id: string, +): Promise { + return db.transaction( + async (tx) => (await review(tx, await read(tx, id), false)).preview, + { isolationLevel: "repeatable read" }, + ); +} +async function insertRow( + tx: PackageTx, + table: string, + row: Record, +): Promise { + const [result] = await tx.execute( + sql`INSERT INTO ${sql.identifier(table)} SET ${sql.join( + Object.entries(row) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => sql`${sql.identifier(key)}=${value}`), + sql`, `, + )}`, + ); + const id = Number( + row.id ?? (result as unknown as { insertId: number }).insertId, + ); + if (!Number.isSafeInteger(id) || id <= 0) + throw Error("Could not allocate package row ID"); + return id; +} +async function updateRow( + tx: PackageTx, + table: string, + id: number, + row: Record, +) { + await tx.execute( + sql`UPDATE ${sql.identifier(table)} SET ${sql.join( + Object.entries(row).map( + ([key, value]) => sql`${sql.identifier(key)}=${value}`, + ), + sql`, `, + )} WHERE id=${id}`, + ); +} +async function publishCatalogPackageAttempt( + raw: PublishCatalogPackageInput, +): Promise { + const input = publishPackageSchema.parse(raw); + const initial = await getCatalogPackageCommand(input.id); + // Reserve offer identifiers outside catalog row locks, following the normal importer lock order. + const reserved: number[] = []; + if (initial.mode === "copy" && initial.status === "draft") + for (let i = 0; i < initial.draft.offers.length; i++) + reserved.push(await allocateCatalogItemId(async (id) => id)); + return db.transaction( + async (tx) => { + const stored = await read(tx, input.id, true), + p = stored.package; + if (p.status === "published" && stored.publication) + return { ...stored.publication, alreadyPublished: true }; + if (p.version !== input.version) + throw new CatalogConflict( + "Package changed. Refresh the publication preview", + ); + const { preview, source, pages } = await review(tx, stored, true); + if ( + preview.conflicts.length || + preview.fingerprint !== input.fingerprint || + !source + ) + throw new CatalogConflict( + preview.conflicts.join(" ") || + "Catalog changed. Refresh the publication preview", + ); + const pageIds: number[] = []; + if (p.mode === "update") { + for (const page of p.draft.pages) { + await updateRow( + tx, + "catalog_pages", + page.id, + columns(page, pageColumns), + ); + pageIds.push(page.id); + } + for (const offer of p.draft.offers) + await updateRow( + tx, + "catalog_items", + offer.id, + columns(offer, offerColumns), + ); + } else { + if (reserved.length !== source.offers.length) + throw new CatalogConflict( + "Package changed. Refresh the publication preview", + ); + const map = new Map(), + selected = new Set(source.pages.map((page) => Number(page.id))); + const pending = [...p.draft.pages]; + while (pending.length) { + const index = pending.findIndex( + (page) => !selected.has(page.parentId) || map.has(page.parentId), + ); + if (index < 0) + throw new CatalogInputError("Package hierarchy contains a cycle"); + const [page] = pending.splice(index, 1), + original = source.pages.find((row) => Number(row.id) === page.id); + if (!original) + throw new CatalogConflict( + "Package source category no longer exists", + ); + const { id: _id, ...row } = original; + Object.assign(row, columns(page, pageColumns), { + parent_id: map.get(page.parentId) ?? p.targetParentId, + includes: "", + }); + const id = await insertRow(tx, "catalog_pages", row); + map.set(page.id, id); + pageIds.push(id); + } + const existing = new Set(pages.map((page) => Number(page.id))); + for (const page of source.pages) + if (page.includes) { + const includes = remapIncludes(page.includes, map, existing); + if (includes.length > 128) + throw new CatalogInputError( + "Remapped included category references exceed 128 characters", + ); + await updateRow( + tx, + "catalog_pages", + Number(map.get(Number(page.id))), + { + includes, + }, + ); + } + const offerMap = new Map( + source.offers.map((offer, index) => [ + Number(offer.id), + reserved[index], + ]), + ); + for (const offer of p.draft.offers) { + const original = source.offers.find( + (row) => Number(row.id) === offer.id, + ); + if (!original) + throw new CatalogConflict("Package source offer no longer exists"); + const row: Record = { + ...original, + ...columns(offer, offerColumns), + id: offerMap.get(offer.id), + page_id: String(map.get(offer.pageId)), + }; + if ( + Number(original.offer_id) > 0 && + offerMap.has(Number(original.offer_id)) + ) + row.offer_id = offerMap.get(Number(original.offer_id)); + await insertRow(tx, "catalog_items", row); + } + } + const result: PackagePublication = { + pageIds, + offerCount: p.draft.offers.length, + alreadyPublished: false, + }; + p.status = "published"; + p.version++; + p.publishedAt = new Date().toISOString(); + p.updatedAt = p.publishedAt; + stored.publication = result; + await write(tx, stored); + return result; + }, + { isolationLevel: "repeatable read" }, + ); +} + +/** An ID reserved by another process can race our process-local allocator. A collision rolls back the entire copy before retry. */ +export async function publishCatalogPackageCommand( + input: PublishCatalogPackageInput, +): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await publishCatalogPackageAttempt(input); + } catch (error) { + let cause: unknown = error, + duplicate = false; + for ( + let depth = 0; + cause && typeof cause === "object" && depth < 5; + depth++ + ) { + const record = cause as { + code?: string; + errno?: number; + cause?: unknown; + }; + if (record.code === "ER_DUP_ENTRY" || record.errno === 1062) { + duplicate = true; + break; + } + cause = record.cause; + } + if (!duplicate || attempt >= 2) throw error; + } + } +} diff --git a/src/messages/ar.json b/src/messages/ar.json index 7a6b5229..a647c18f 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -126,6 +126,161 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalog": { + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" + } + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "myDashboard": { diff --git a/src/messages/bg.json b/src/messages/bg.json index bf9786e1..ba62b043 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -1429,6 +1429,96 @@ "save": "Запазете елемента", "saving": "Запазва се...", "cancel": "Отказ" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/cs.json b/src/messages/cs.json index 56ca72c6..c3f7b9c8 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -1429,6 +1429,96 @@ "save": "Uložit položku", "saving": "Ukládání…", "cancel": "Zrušit" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/da.json b/src/messages/da.json index 133cb677..c035bcdd 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -1429,6 +1429,96 @@ "save": "Save item", "saving": "Saving…", "cancel": "Annuller" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/de.json b/src/messages/de.json index b0cb66ed..b0cab306 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -1400,6 +1400,96 @@ "save": "Artikel speichern", "saving": "Wird gespeichert…", "cancel": "Abbrechen" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3055,6 +3145,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "radioRequests": { diff --git a/src/messages/el.json b/src/messages/el.json index 4ca4d491..d303a5c5 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -1429,6 +1429,96 @@ "save": "Αποθήκευση στοιχείου", "saving": "Αποθήκευση…", "cancel": "Ακύρωση" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/en.json b/src/messages/en.json index 754d81fa..ccb9504d 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2089,6 +2089,96 @@ "selectionHint": "Changing the search or filter clears the selection.", "noResults": "No offers match the current search and filter.", "selectVisible": "Select all visible offers" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3881,6 +3971,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "mod": { diff --git a/src/messages/es.json b/src/messages/es.json index 8f5240e0..fac229ca 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -1400,6 +1400,96 @@ "save": "Guardar artículo", "saving": "Guardando…", "cancel": "Cancelar" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3055,6 +3145,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "radioRequests": { diff --git a/src/messages/fi.json b/src/messages/fi.json index 5e5e30b0..06e54dcc 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -126,6 +126,161 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalog": { + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" + } + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "myDashboard": { diff --git a/src/messages/fr.json b/src/messages/fr.json index d537065c..7877b30b 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -1400,6 +1400,96 @@ "save": "Enregistrer l'article", "saving": "Enregistrement…", "cancel": "Annuler" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3055,6 +3145,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "radioRequests": { diff --git a/src/messages/hr.json b/src/messages/hr.json index 3a8a9e81..48d1f938 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -1429,6 +1429,96 @@ "save": "Spremi stavku", "saving": "Spremanje...", "cancel": "Odustani" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/hu.json b/src/messages/hu.json index f5fb210d..cdb7718e 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -1429,6 +1429,96 @@ "save": "Mentse el az elemet", "saving": "Mentés…", "cancel": "Mégse" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/it.json b/src/messages/it.json index 6e58009c..3537f755 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -2008,6 +2008,96 @@ "selectionHint": "Cambiare ricerca o filtro azzera la selezione.", "noResults": "Nessuna offerta corrisponde alla ricerca e al filtro attuali.", "selectVisible": "Seleziona tutte le offerte visibili" + }, + "packages": { + "openPackages": "Pacchetti catalogo", + "title": "Pacchetti catalogo", + "description": "Prepara modifiche a categorie e prezzi in una bozza separata. Controlla il catalogo prima di pubblicare.", + "createPackage": "Nuovo pacchetto", + "createDraft": "Crea bozza", + "draftCreated": "Bozza del pacchetto creata. Il catalogo live non è stato modificato.", + "refresh": "Aggiorna elenco", + "searchPackages": "Cerca pacchetti", + "noPackages": "Nessun pacchetto corrisponde alla ricerca.", + "choosePackage": "Apri un pacchetto o crea una bozza dalle categorie esistenti.", + "name": "Nome pacchetto", + "mode": "Modalità pubblicazione", + "update": "Aggiorna categorie esistenti", + "copy": "Crea copie", + "updateHint": "La pubblicazione applica il pacchetto alle categorie e offerte esistenti.", + "copyHint": "La pubblicazione crea nuove categorie e offerte sotto il genitore scelto. I riferimenti ai furni vengono mantenuti.", + "sourceCategories": "Categorie di origine", + "packageLimits": "Sono incluse le sottocategorie. Ogni pacchetto supporta fino a 200 categorie e 500 offerte.", + "searchCategories": "Cerca categorie", + "selectedCategories": "Selezionate: {count}", + "targetParent": "Categoria genitore di destinazione", + "rootCategory": "Radice del catalogo", + "draft": "Bozza", + "published": "Pubblicato", + "editDraft": "Modifica bozza", + "catalogPreview": "Anteprima catalogo", + "showBaseline": "Mostra l’istantanea originale salvata", + "changes": "Prima e dopo", + "entry": "Voce", + "field": "Campo", + "before": "Prima", + "after": "Dopo", + "noChanges": "Nessun campo modificato rispetto all’istantanea originale.", + "copyReview": "Alla pubblicazione sarà copiato l’intero pacchetto, anche senza campi modificati. La tabella elenca le modifiche all’istantanea originale.", + "updateReview": "Queste modifiche della bozza aggiorneranno le voci esistenti dopo la pubblicazione.", + "saveDraft": "Salva bozza", + "saveHint": "Il salvataggio mantiene il pacchetto come bozza. Salva tutte le modifiche prima di richiedere la verifica di pubblicazione.", + "draftSaved": "Bozza salvata. La pubblicazione è un passaggio separato.", + "reviewPublication": "Verifica pubblicazione", + "publicationImpact": "La pubblicazione interessa {pages} categorie e {offers} offerte.", + "conflicts": "La pubblicazione è bloccata da conflitti. Risolvili prima di richiedere una nuova verifica.", + "readyToPublish": "La versione salvata ha superato la verifica. La pubblicazione modifica il catalogo live.", + "publish": "Pubblica pacchetto", + "confirmPublish": "Pubblicare ora questo pacchetto verificato nel catalogo live?", + "publishedSuccess": "Pacchetto pubblicato: {pages} categorie e {offers} offerte.", + "staleReview": "Questo pacchetto è cambiato in un’altra sessione. Riaprilo per verificare l’ultima versione salvata.", + "unsaved": "Modifiche non salvate", + "discardChanges": "Scartare le modifiche non salvate al pacchetto e continuare?", + "working": "Operazione in corso…", + "requestFailed": "La richiesta non è riuscita. Riprova.", + "invalidFields": "Controlla nomi obbligatori, layout e campi numerici interi prima di salvare.", + "category": "Categoria", + "caption": "Nome categoria", + "iconImage": "ID icona categoria", + "minRank": "Rango minimo", + "orderNum": "Ordine di visualizzazione", + "pageLayout": "Layout pagina", + "enabled": "Abilitata", + "visible": "Visibile", + "clubOnly": "Solo membri Club", + "vipOnly": "Solo membri VIP", + "offers": "Offerte ({count})", + "offer": "Offerta", + "catalogName": "Nome offerta", + "costCredits": "Crediti", + "costPoints": "Punti", + "pointsType": "ID valuta punti", + "amount": "Quantità", + "searchOffers": "Cerca offerte", + "currentCategoryOffers": "Solo la categoria selezionata", + "selectMatching": "Seleziona tutte le offerte filtrate ({count})", + "clearSelection": "Svuota selezione", + "selectedOffers": "Offerte selezionate: {count}", + "select": "Seleziona", + "selectOffer": "Seleziona offerta #{id}", + "noOffers": "Nessuna offerta corrisponde ai filtri.", + "bulkPrices": "Prezzi della bozza in blocco ({count} selezionate)", + "bulkHint": "Controlla i prezzi selezionati, poi accettali nella bozza. Il catalogo live resta invariato fino alla pubblicazione.", + "operation": "Operazione", + "set": "Imposta valore", + "add": "Aggiungi o sottrai", + "percent": "Variazione percentuale", + "value": "Valore", + "reviewBulk": "Anteprima prezzi", + "invalidBulk": "Scegli numeri validi e almeno un’offerta. I prezzi risultanti devono essere non negativi e nell’intervallo supportato.", + "acceptBulk": "Accetta prezzi nella bozza", + "previous": "Precedente", + "next": "Successiva" } }, "shop": { @@ -3856,6 +3946,69 @@ "publications": "Bozze delle news", "unavailable": "{source}: dati temporaneamente non disponibili.", "installation": "Diagnostica installazione" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Le categorie o le offerte sorgenti sono cambiate. Crea un nuovo pacchetto per includere i dati aggiornati.", + "saveConflict": "Il pacchetto è cambiato. Ricaricalo prima di salvare.", + "publishConflict": "Il pacchetto è cambiato. Aggiorna l’anteprima di pubblicazione.", + "catalogChanged": "Il catalogo è cambiato. Aggiorna l’anteprima di pubblicazione.", + "missing": "Pacchetto catalogo non trovato.", + "pageLimit": "Un pacchetto supporta al massimo 200 categorie, incluse le sottocategorie.", + "offerLimit": "Un pacchetto supporta al massimo 500 offerte.", + "payloadLimit": "I dati del pacchetto superano il limite di 8 MB.", + "membership": "Non puoi aggiungere o rimuovere elementi dal pacchetto.", + "hierarchy": "Non puoi modificare la gerarchia del pacchetto.", + "references": "Non puoi modificare i riferimenti ai furni del pacchetto.", + "furnitureMissing": "Un furni referenziato dal pacchetto non esiste più.", + "auditWarning": "Pacchetto pubblicato, ma la registrazione nell’audit non è riuscita.", + "hotelWarning": "Pacchetto pubblicato, ma l’aggiornamento non è stato inviato all’hotel.", + "exportWarning": "Pacchetto pubblicato, ma la pianificazione dell’esportazione non è riuscita.", + "refreshWarning": "Pacchetto pubblicato. Aggiorna la pagina per vedere il catalogo aggiornato." + } + }, + "catalogPackagePreview": { + "title": "Anteprima catalogo", + "hint": "Sfoglia le pagine e le offerte reali del pacchetto. Questa è un’anteprima del catalogo, non la riproduzione completa del client di gioco. Non vengono effettuati acquisti.", + "search": "Cerca categorie o offerte", + "playerMode": "Accesso giocatore", + "rank": "Rango", + "club": "Club", + "vip": "VIP", + "externalParents": "La selezione appartiene a un catalogo più ampio. Non è possibile valutare le restrizioni delle categorie superiori esterne a questa istantanea.", + "invalidHierarchy": "{count} pagine hanno una gerarchia ciclica e non sono navigabili.", + "snapshotLimit": "Anteprima limitata a 200 pagine e 500 offerte.", + "restricted": "{count} pagine sono nascoste dalle impostazioni di accesso selezionate.", + "categories": "Categorie del catalogo", + "noPages": "Questa istantanea non contiene categorie.", + "inaccessible": "Nessuna categoria è accessibile con queste impostazioni. Modifica l’accesso giocatore o disattivalo per esaminare la bozza.", + "noMatches": "Nessun risultato per questa ricerca.", + "offerCount": "{count} offerte", + "layout": "Layout: {layout}", + "missingLayout": "Non specificato", + "unsupportedLayout": "Questo layout non ha un’anteprima dedicata. La griglia mostra le offerte reali; componenti e immagini del layout personalizzato non vengono riprodotti.", + "loadingIcons": "Caricamento delle icone dei furni…", + "assetsUnavailable": "I dettagli dei furni non sono al momento disponibili. I dati delle offerte restano visibili.", + "assetLimit": "Icone e nomi dei furni sono limitati ai primi 200 ID di questa vista.", + "emptyPage": "Questa categoria non contiene offerte dirette. Sfoglia le sottocategorie.", + "missingIcon": "Icona non disponibile", + "amount": "Quantità: {count}", + "bundle": "{count} tipi di furni", + "changed": "Modificato rispetto alla versione iniziale salvata", + "previous": "Precedente", + "next": "Successiva", + "page": "Pagina {page} di {total}", + "offerId": "Offerta #{id}", + "unknownFurniture": "Nome del furni non disponibile", + "selectOffer": "Seleziona un’offerta per esaminare il prezzo e i furni inclusi.", + "credits": "Crediti", + "duckets": "Duckets", + "diamonds": "Diamanti", + "pointsType": "Punti (tipo {type}):", + "free": "Gratis", + "specialPricing": "Prezzo speciale: {credits} crediti, {points} punti (tipo {type})", + "restrictedOffers": "{count} offerte in questa categoria richiedono l’iscrizione al Club.", + "clubOffer": "Offerta Club" } }, "radioRequests": { diff --git a/src/messages/ja.json b/src/messages/ja.json index 2b36a062..0dae330d 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -126,6 +126,161 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalog": { + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" + } + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "myDashboard": { diff --git a/src/messages/nl.json b/src/messages/nl.json index 979ca9ab..f0b0de6b 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -1838,6 +1838,96 @@ "selectionHint": "De selectie wordt gewist wanneer je de zoekopdracht of het filter wijzigt.", "noResults": "Geen aanbiedingen passen bij de huidige zoekopdracht en het filter.", "selectVisible": "Alle zichtbare aanbiedingen selecteren" + }, + "packages": { + "openPackages": "Cataloguspakketten", + "title": "Cataloguspakketten", + "description": "Bereid categorie- en prijswijzigingen voor in een apart concept. Controleer de catalogus voordat je publiceert.", + "createPackage": "Nieuw pakket", + "createDraft": "Concept maken", + "draftCreated": "Pakketconcept gemaakt. De livecatalogus is niet gewijzigd.", + "refresh": "Lijst vernieuwen", + "searchPackages": "Pakketten zoeken", + "noPackages": "Geen pakketten gevonden.", + "choosePackage": "Open een pakket of maak een concept op basis van bestaande categorieën.", + "name": "Pakketnaam", + "mode": "Publicatiemodus", + "update": "Bestaande categorieën bijwerken", + "copy": "Kopieën maken", + "updateHint": "Bij publicatie worden de bestaande categorieën en aanbiedingen bijgewerkt.", + "copyHint": "Bij publicatie worden nieuwe categorieën en aanbiedingen onder de gekozen bovenliggende categorie gemaakt. Meubelverwijzingen blijven behouden.", + "sourceCategories": "Broncategorieën", + "packageLimits": "Subcategorieën worden meegenomen. Elk pakket ondersteunt maximaal 200 categorieën en 500 aanbiedingen.", + "searchCategories": "Categorieën zoeken", + "selectedCategories": "Geselecteerd: {count}", + "targetParent": "Bovenliggende doelcategorie", + "rootCategory": "Hoofdniveau van de catalogus", + "draft": "Concept", + "published": "Gepubliceerd", + "editDraft": "Concept bewerken", + "catalogPreview": "Catalogusvoorbeeld", + "showBaseline": "Oorspronkelijke opgeslagen momentopname tonen", + "changes": "Voor en na", + "entry": "Onderdeel", + "field": "Veld", + "before": "Voor", + "after": "Na", + "noChanges": "Geen velden gewijzigd ten opzichte van de oorspronkelijke momentopname.", + "copyReview": "Het volledige pakket wordt bij publicatie gekopieerd, ook zonder veldwijzigingen. De tabel toont wijzigingen ten opzichte van de oorspronkelijke momentopname.", + "updateReview": "Deze conceptwijzigingen worden na publicatie op bestaande catalogusonderdelen toegepast.", + "saveDraft": "Concept opslaan", + "saveHint": "Opslaan bewaart het pakket als concept. Sla alle wijzigingen op voordat je een publicatiecontrole aanvraagt.", + "draftSaved": "Concept opgeslagen. Publiceren is een aparte stap.", + "reviewPublication": "Publicatie controleren", + "publicationImpact": "Publicatie betreft {pages} categorieën en {offers} aanbiedingen.", + "conflicts": "Publicatie wordt geblokkeerd door conflicten. Los deze op voordat je opnieuw controleert.", + "readyToPublish": "De opgeslagen versie heeft de publicatiecontrole doorstaan. Publiceren wijzigt de livecatalogus.", + "publish": "Pakket publiceren", + "confirmPublish": "Dit gecontroleerde pakket nu publiceren in de livecatalogus?", + "publishedSuccess": "Pakket gepubliceerd: {pages} categorieën en {offers} aanbiedingen.", + "staleReview": "Dit pakket is in een andere sessie gewijzigd. Open het opnieuw om de laatste opgeslagen versie te controleren.", + "unsaved": "Niet-opgeslagen wijzigingen", + "discardChanges": "Niet-opgeslagen pakketwijzigingen verwerpen en doorgaan?", + "working": "Bezig…", + "requestFailed": "Het verzoek is mislukt. Probeer het opnieuw.", + "invalidFields": "Controleer verplichte namen, lay-outs en gehele getallen voordat je opslaat.", + "category": "Categorie", + "caption": "Categorienaam", + "iconImage": "Categoriepictogram-ID", + "minRank": "Minimale rang", + "orderNum": "Weergavevolgorde", + "pageLayout": "Paginalay-out", + "enabled": "Ingeschakeld", + "visible": "Zichtbaar", + "clubOnly": "Alleen Clubleden", + "vipOnly": "Alleen VIP-leden", + "offers": "Aanbiedingen ({count})", + "offer": "Aanbieding", + "catalogName": "Aanbiedingsnaam", + "costCredits": "Credits", + "costPoints": "Punten", + "pointsType": "Puntenvaluta-ID", + "amount": "Aantal", + "searchOffers": "Aanbiedingen zoeken", + "currentCategoryOffers": "Alleen de geselecteerde categorie", + "selectMatching": "Alle overeenkomende aanbiedingen selecteren ({count})", + "clearSelection": "Selectie wissen", + "selectedOffers": "Geselecteerde aanbiedingen: {count}", + "select": "Selecteren", + "selectOffer": "Aanbieding #{id} selecteren", + "noOffers": "Geen aanbiedingen gevonden met deze filters.", + "bulkPrices": "Conceptprijzen in bulk ({count} geselecteerd)", + "bulkHint": "Bekijk de geselecteerde prijswijzigingen en neem ze daarna over in dit concept. De livecatalogus blijft ongewijzigd tot publicatie.", + "operation": "Bewerking", + "set": "Waarde instellen", + "add": "Optellen of aftrekken", + "percent": "Procentueel aanpassen", + "value": "Waarde", + "reviewBulk": "Prijsvoorbeeld", + "invalidBulk": "Kies geldige getallen en minstens één aanbieding. De resulterende prijzen mogen niet negatief zijn en moeten binnen het ondersteunde bereik vallen.", + "acceptBulk": "Prijzen overnemen in concept", + "previous": "Vorige", + "next": "Volgende" } }, "shop": { @@ -3430,6 +3520,69 @@ "publications": "Nieuwsconcepten", "unavailable": "{source}: gegevens tijdelijk niet beschikbaar.", "installation": "Installatiediagnose" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "De broncategorieën of aanbiedingen zijn gewijzigd. Maak een nieuw pakket met de actuele gegevens.", + "saveConflict": "Het pakket is gewijzigd. Laad het opnieuw voordat je opslaat.", + "publishConflict": "Het pakket is gewijzigd. Vernieuw het publicatievoorbeeld.", + "catalogChanged": "De catalogus is gewijzigd. Vernieuw het publicatievoorbeeld.", + "missing": "Cataloguspakket niet gevonden.", + "pageLimit": "Een pakket ondersteunt maximaal 200 categorieën, inclusief subcategorieën.", + "offerLimit": "Een pakket ondersteunt maximaal 500 aanbiedingen.", + "payloadLimit": "De pakketgegevens overschrijden de limiet van 8 MB.", + "membership": "Je kunt geen onderdelen aan het pakket toevoegen of eruit verwijderen.", + "hierarchy": "Je kunt de pakkethiërarchie niet wijzigen.", + "references": "Je kunt de meubelverwijzingen in het pakket niet wijzigen.", + "furnitureMissing": "Een meubel waarnaar het pakket verwijst bestaat niet meer.", + "auditWarning": "Pakket gepubliceerd, maar het schrijven naar het auditlog is mislukt.", + "hotelWarning": "Pakket gepubliceerd, maar de hotelupdate is niet afgeleverd.", + "exportWarning": "Pakket gepubliceerd, maar het plannen van de export is mislukt.", + "refreshWarning": "Pakket gepubliceerd. Vernieuw de pagina om de nieuwste catalogus te zien." + } + }, + "catalogPackagePreview": { + "title": "Catalogusvoorbeeld", + "hint": "Blader door de echte pagina’s en aanbiedingen van het pakket. Dit is een catalogusvoorbeeld, geen volledige weergave van de spelclient. Er worden geen aankopen gedaan.", + "search": "Categorieën of aanbiedingen zoeken", + "playerMode": "Spelerstoegang", + "rank": "Rang", + "club": "Club", + "vip": "VIP", + "externalParents": "Deze selectie is onderdeel van een grotere catalogus. Toegangsregels van bovenliggende categorieën buiten deze momentopname kunnen niet worden beoordeeld.", + "invalidHierarchy": "{count} pagina’s hebben een cyclische hiërarchie en kunnen niet worden geopend.", + "snapshotLimit": "Voorbeeld beperkt tot 200 pagina’s en 500 aanbiedingen.", + "restricted": "{count} pagina’s zijn verborgen door de gekozen toegangsinstellingen.", + "categories": "Cataloguscategorieën", + "noPages": "Deze momentopname bevat geen categorieën.", + "inaccessible": "Met deze instellingen zijn geen categorieën toegankelijk. Pas de spelerstoegang aan of schakel deze uit om het concept te bekijken.", + "noMatches": "Geen resultaten voor deze zoekopdracht.", + "offerCount": "{count} aanbiedingen", + "layout": "Indeling: {layout}", + "missingLayout": "Niet opgegeven", + "unsupportedLayout": "Deze indeling heeft geen eigen voorbeeld. Het raster toont de echte aanbiedingen; aangepaste onderdelen en afbeeldingen worden niet weergegeven.", + "loadingIcons": "Meubelpictogrammen laden…", + "assetsUnavailable": "Meubelgegevens zijn tijdelijk niet beschikbaar. De aanbiedingen blijven zichtbaar.", + "assetLimit": "Pictogrammen en meubelnamen zijn beperkt tot de eerste 200 item-ID’s in deze weergave.", + "emptyPage": "Deze categorie bevat geen directe aanbiedingen. Bekijk de subcategorieën.", + "missingIcon": "Pictogram niet beschikbaar", + "amount": "Aantal: {count}", + "bundle": "{count} meubeltypes", + "changed": "Gewijzigd ten opzichte van de opgeslagen beginversie", + "previous": "Vorige", + "next": "Volgende", + "page": "Pagina {page} van {total}", + "offerId": "Aanbieding #{id}", + "unknownFurniture": "Meubelnaam niet beschikbaar", + "selectOffer": "Selecteer een aanbieding om de prijs en meubelverwijzingen te bekijken.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamanten", + "pointsType": "Punten (type {type}):", + "free": "Gratis", + "specialPricing": "Speciale prijs: {credits} credits, {points} punten (type {type})", + "restrictedOffers": "{count} aanbiedingen in deze categorie vereisen een Club-lidmaatschap.", + "clubOffer": "Club-aanbieding" } }, "mod": { diff --git a/src/messages/no.json b/src/messages/no.json index 64a8002f..b96296b6 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -1429,6 +1429,96 @@ "save": "Lagre element", "saving": "Lagrer …", "cancel": "Avbryt" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/pl.json b/src/messages/pl.json index 9e7ea063..1b37fca5 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -1430,6 +1430,96 @@ "save": "Zapisz element", "saving": "Zapisuję…", "cancel": "Anuluj" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3085,6 +3175,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/pt.json b/src/messages/pt.json index b6ad46c6..f03f59f9 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -1430,6 +1430,96 @@ "save": "Salvar item", "saving": "Salvando…", "cancel": "Cancelar" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3085,6 +3175,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/ro.json b/src/messages/ro.json index e0bcdb99..cbfbf851 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -1429,6 +1429,96 @@ "save": "Salvați elementul", "saving": "Se salvează…", "cancel": "Anulează" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/ru.json b/src/messages/ru.json index fcff15a4..af6d892f 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -1428,6 +1428,96 @@ "save": "Save item", "saving": "Saving…", "cancel": "Cancel" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3083,6 +3173,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/sk.json b/src/messages/sk.json index 45436196..8404bd9f 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -1429,6 +1429,96 @@ "save": "Uložiť položku", "saving": "Ukladá sa…", "cancel": "Zrušiť" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/sr.json b/src/messages/sr.json index 21a95408..4950932f 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -1429,6 +1429,96 @@ "save": "Сачувај ставку", "saving": "Чување…", "cancel": "Откажи" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/sv.json b/src/messages/sv.json index 3af42e2b..99a1d8f8 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -1429,6 +1429,96 @@ "save": "Spara objekt", "saving": "Sparar...", "cancel": "Avbryt" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/tr.json b/src/messages/tr.json index ea9d592f..f2e7fb70 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -1430,6 +1430,96 @@ "save": "Öğeyi kaydet", "saving": "Kaydediliyor…", "cancel": "İptal" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3085,6 +3175,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": { diff --git a/src/messages/uk.json b/src/messages/uk.json index 04955def..d614d2a9 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -1429,6 +1429,96 @@ "save": "Зберегти товар", "saving": "Збереження…", "cancel": "Скасувати" + }, + "packages": { + "openPackages": "Catalog packages", + "title": "Catalog packages", + "description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.", + "createPackage": "New package", + "createDraft": "Create draft", + "draftCreated": "Package draft created. The live catalog has not changed.", + "refresh": "Refresh list", + "searchPackages": "Search packages", + "noPackages": "No packages match this search.", + "choosePackage": "Open a package or create a draft from existing categories.", + "name": "Package name", + "mode": "Publication mode", + "update": "Update existing categories", + "copy": "Create copies", + "updateHint": "Publication applies the package to its existing categories and offers.", + "copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.", + "sourceCategories": "Source categories", + "packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.", + "searchCategories": "Search categories", + "selectedCategories": "Selected: {count}", + "targetParent": "Destination parent category", + "rootCategory": "Catalog root", + "draft": "Draft", + "published": "Published", + "editDraft": "Edit draft", + "catalogPreview": "Catalog preview", + "showBaseline": "Show the original saved snapshot", + "changes": "Before and after", + "entry": "Entry", + "field": "Field", + "before": "Before", + "after": "After", + "noChanges": "No edited fields compared with the original snapshot.", + "copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.", + "updateReview": "These draft changes will update existing catalog entries after publication.", + "saveDraft": "Save draft", + "saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.", + "draftSaved": "Draft saved. Publication is a separate step.", + "reviewPublication": "Review publication", + "publicationImpact": "Publication affects {pages} categories and {offers} offers.", + "conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.", + "readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.", + "publish": "Publish package", + "confirmPublish": "Publish this reviewed package to the live catalog now?", + "publishedSuccess": "Package published: {pages} categories and {offers} offers.", + "staleReview": "This package changed in another session. Reopen it to review the latest saved version.", + "unsaved": "Unsaved changes", + "discardChanges": "Discard unsaved package changes and continue?", + "working": "Working…", + "requestFailed": "The request failed. Try again.", + "invalidFields": "Check required names, layouts and whole-number fields before saving.", + "category": "Category", + "caption": "Category caption", + "iconImage": "Category icon ID", + "minRank": "Minimum rank", + "orderNum": "Display order", + "pageLayout": "Page layout", + "enabled": "Enabled", + "visible": "Visible", + "clubOnly": "Club members only", + "vipOnly": "VIP members only", + "offers": "Offers ({count})", + "offer": "Offer", + "catalogName": "Offer name", + "costCredits": "Credits", + "costPoints": "Points", + "pointsType": "Points currency ID", + "amount": "Amount", + "searchOffers": "Search offers", + "currentCategoryOffers": "Only the selected category", + "selectMatching": "Select all matching offers ({count})", + "clearSelection": "Clear selection", + "selectedOffers": "Selected offers: {count}", + "select": "Select", + "selectOffer": "Select offer #{id}", + "noOffers": "No offers match these filters.", + "bulkPrices": "Bulk draft prices ({count} selected)", + "bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.", + "operation": "Operation", + "set": "Set value", + "add": "Add or subtract", + "percent": "Adjust by percent", + "value": "Value", + "reviewBulk": "Preview prices", + "invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.", + "acceptBulk": "Accept prices into draft", + "previous": "Previous", + "next": "Next" } }, "shop": { @@ -3084,6 +3174,69 @@ "publications": "News drafts", "unavailable": "{source}: data is temporarily unavailable.", "installation": "Installation diagnostics" + }, + "catalogPackages": { + "backend": { + "sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.", + "saveConflict": "Package changed. Reload it before saving", + "publishConflict": "Package changed. Refresh the publication preview", + "catalogChanged": "Catalog changed. Refresh the publication preview", + "missing": "Catalog package not found", + "pageLimit": "A package supports at most 200 categories including descendants", + "offerLimit": "A package supports at most 500 offers", + "payloadLimit": "Package data exceeds 8 MB safety limit", + "membership": "Package membership cannot change", + "hierarchy": "Package hierarchy cannot change", + "references": "Package furniture references cannot change", + "furnitureMissing": "Package furniture reference no longer exists", + "auditWarning": "Package published, but audit logging failed.", + "hotelWarning": "Package published, but the hotel update was not delivered.", + "exportWarning": "Package published, but export scheduling failed.", + "refreshWarning": "Package published. Refresh the page to see the latest catalog." + } + }, + "catalogPackagePreview": { + "title": "Catalog preview", + "hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.", + "search": "Search categories or offers", + "playerMode": "Player access", + "rank": "Rank", + "club": "Club", + "vip": "VIP", + "externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.", + "invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.", + "snapshotLimit": "Preview limited to 200 pages and 500 offers.", + "restricted": "{count} pages are hidden by the selected player access settings.", + "categories": "Catalog categories", + "noPages": "This snapshot contains no categories.", + "inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.", + "noMatches": "No matches for this search.", + "offerCount": "{count} offers", + "layout": "Layout: {layout}", + "missingLayout": "Not specified", + "unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.", + "loadingIcons": "Loading furniture icons…", + "assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.", + "assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.", + "emptyPage": "This category has no direct offers. Browse its subcategories.", + "missingIcon": "Icon unavailable", + "amount": "Quantity: {count}", + "bundle": "{count} furniture types", + "changed": "Changed from the saved baseline", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {total}", + "offerId": "Offer #{id}", + "unknownFurniture": "Furniture name unavailable", + "selectOffer": "Select an offer to inspect its price and furniture references.", + "credits": "Credits", + "duckets": "Duckets", + "diamonds": "Diamonds", + "pointsType": "Points (type {type}):", + "free": "Free", + "specialPricing": "Special pricing: {credits} credits, {points} points (type {type})", + "restrictedOffers": "{count} offers in this category require Club membership.", + "clubOffer": "Club offer" } }, "error": {