feat(catalog): add saved packages, preview and bulk price editing
This commit is contained in:
1 parent
867113d5d4
commit
047cd9f3dd
48 files changed
+7712
No files matched your search
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<T>(operation: () => Promise<T>) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||
<CatalogPackagesDialog canEdit={canEdit} />
|
||||
{canEdit && (
|
||||
<CatalogManagerDialog
|
||||
catalogType="normal"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
int,
|
||||
longtext,
|
||||
mysqlTable,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
export const CatalogPackages = mysqlTable("website_catalog_packages", {
|
||||
id: varchar("id", { length: 36 }).primaryKey(),
|
||||
name: varchar("name", { length: 128 }).notNull(),
|
||||
version: int("version", { unsigned: true }).notNull(),
|
||||
status: varchar("status", { length: 16 }).notNull(),
|
||||
mode: varchar("mode", { length: 16 }).notNull(),
|
||||
payload: longtext("payload").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPackagePreview,
|
||||
createPreviewAssetLoader,
|
||||
parsePreviewItemIds,
|
||||
} from "./catalog-package-preview-model";
|
||||
|
||||
const page = (id: number, parentId = -1, overrides = {}) => ({
|
||||
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<number[]>((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<number[]>((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);
|
||||
});
|
||||
});
|
||||
@@ -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<number>();
|
||||
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<number, number> = {};
|
||||
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<number, typeof inputPages>();
|
||||
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<T>(
|
||||
load: (ids: number[]) => Promise<T>,
|
||||
) {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<span className="px-2 text-center text-[10px] text-muted-foreground">
|
||||
{missing}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<img
|
||||
src={getLocalIconUrl(asset.classname)}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
loading="lazy"
|
||||
className="h-16 w-16 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function PreviewPrice({ offer }: { offer: PackageOffer }) {
|
||||
const t = useTranslations("pages.admin.catalogPackagePreview");
|
||||
if (offer.costCredits < 0 || offer.costPoints < 0)
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{t("specialPricing", {
|
||||
credits: offer.costCredits,
|
||||
points: offer.costPoints,
|
||||
type: offer.pointsType,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-2 text-xs">
|
||||
{offer.costCredits > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<CurrencyIcon kind="credits" size={14} alt={t("credits")} />
|
||||
{offer.costCredits}
|
||||
</span>
|
||||
)}
|
||||
{offer.costPoints > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{offer.pointsType === 0 || offer.pointsType === 5 ? (
|
||||
<CurrencyIcon
|
||||
kind={offer.pointsType === 5 ? "diamonds" : "duckets"}
|
||||
size={14}
|
||||
alt={t(offer.pointsType === 5 ? "diamonds" : "duckets")}
|
||||
/>
|
||||
) : (
|
||||
<span>{t("pointsType", { type: offer.pointsType })}</span>
|
||||
)}
|
||||
{offer.costPoints}
|
||||
</span>
|
||||
)}
|
||||
{offer.costCredits === 0 && offer.costPoints === 0 && (
|
||||
<span>{t("free")}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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<keyof PackageOffer>).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<number | null>(null);
|
||||
const [selectedOfferId, setSelectedOfferId] = useState<number | null>(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 (
|
||||
<section className="min-w-0 space-y-3" aria-label={t("title")}>
|
||||
<p className="text-sm text-muted-foreground">{t("hint")}</p>
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
|
||||
<label
|
||||
htmlFor={`${controlId}-search`}
|
||||
className="grid min-w-0 flex-1 gap-1 text-xs"
|
||||
>
|
||||
<span>{t("search")}</span>
|
||||
<Input
|
||||
id={`${controlId}-search`}
|
||||
value={query}
|
||||
maxLength={191}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setSelectedPageId(null);
|
||||
setSelectedOfferId(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profile.playerMode}
|
||||
onChange={(event) =>
|
||||
setProfile({ ...profile, playerMode: event.target.checked })
|
||||
}
|
||||
/>
|
||||
{t("playerMode")}
|
||||
</label>
|
||||
<label htmlFor={`${controlId}-rank`} className="grid gap-1 text-xs">
|
||||
<span>{t("rank")}</span>
|
||||
<Input
|
||||
id={`${controlId}-rank`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={profile.rank}
|
||||
className="w-20"
|
||||
disabled={!profile.playerMode}
|
||||
onChange={(event) =>
|
||||
setProfile({
|
||||
...profile,
|
||||
rank: Math.min(
|
||||
100,
|
||||
Math.max(0, Number(event.target.value) || 0),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!profile.playerMode}
|
||||
checked={profile.club}
|
||||
onChange={(event) =>
|
||||
setProfile({ ...profile, club: event.target.checked })
|
||||
}
|
||||
/>
|
||||
{t("club")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!profile.playerMode}
|
||||
checked={profile.vip}
|
||||
onChange={(event) =>
|
||||
setProfile({ ...profile, vip: event.target.checked })
|
||||
}
|
||||
/>
|
||||
{t("vip")}
|
||||
</label>
|
||||
</div>
|
||||
{preview.externalParents && (
|
||||
<p className="text-xs text-muted-foreground">{t("externalParents")}</p>
|
||||
)}
|
||||
{preview.invalidCount > 0 && (
|
||||
<p role="status" className="text-xs text-[var(--admin-warning)]">
|
||||
{t("invalidHierarchy", { count: preview.invalidCount })}
|
||||
</p>
|
||||
)}
|
||||
{preview.truncated && (
|
||||
<p role="status" className="text-xs text-[var(--admin-warning)]">
|
||||
{t("snapshotLimit")}
|
||||
</p>
|
||||
)}
|
||||
{preview.restrictedCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("restricted", { count: preview.restrictedCount })}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid min-w-0 gap-3 md:grid-cols-[minmax(150px,210px)_minmax(0,1fr)]">
|
||||
<nav
|
||||
aria-label={t("categories")}
|
||||
className="max-h-64 overflow-auto rounded-lg border p-2 md:max-h-[520px]"
|
||||
>
|
||||
{preview.pages.length ? (
|
||||
<ul className="space-y-1">
|
||||
{preview.pages.map((page) => (
|
||||
<li key={page.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={
|
||||
selectedPage?.id === page.id ? "page" : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedPageId(page.id);
|
||||
setSelectedOfferId(null);
|
||||
}}
|
||||
className={`w-full rounded px-2 py-2 text-left text-xs break-words ${selectedPage?.id === page.id ? "bg-primary/15 font-semibold" : "hover:bg-muted"}`}
|
||||
style={{ paddingLeft: 8 + Math.min(page.depth, 8) * 12 }}
|
||||
>
|
||||
{page.caption}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="p-2 text-xs text-muted-foreground">
|
||||
{snapshot.pages.length === 0
|
||||
? t("noPages")
|
||||
: preview.restrictedCount > 0 && !query
|
||||
? t("inaccessible")
|
||||
: t("noMatches")}
|
||||
</p>
|
||||
)}
|
||||
</nav>
|
||||
<div className="min-w-0 space-y-3">
|
||||
{selectedPage && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-semibold break-all">
|
||||
{selectedPage.caption}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("offerCount", { count: offers.length })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("layout", {
|
||||
layout: selectedPage.pageLayout || t("missingLayout"),
|
||||
})}
|
||||
</p>
|
||||
{!genericLayout && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border p-2 text-xs text-[var(--admin-warning)]"
|
||||
>
|
||||
{t("unsupportedLayout")}
|
||||
</p>
|
||||
)}
|
||||
{assetKey && assetState.key !== assetKey && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{t("loadingIcons")}
|
||||
</p>
|
||||
)}
|
||||
{assetState.key === assetKey &&
|
||||
assetState.status === "failed" && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-xs text-[var(--admin-warning)]"
|
||||
>
|
||||
{t("assetsUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
{allItemIds.length > 200 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("assetLimit")}
|
||||
</p>
|
||||
)}
|
||||
{(preview.restrictedOfferCounts[selectedPage.id] ?? 0) > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("restrictedOffers", {
|
||||
count: preview.restrictedOfferCounts[selectedPage.id],
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{offers.length === 0 ? (
|
||||
<p className="rounded border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{query
|
||||
? t("noMatches")
|
||||
: (preview.restrictedOfferCounts[selectedPage.id] ?? 0) > 0
|
||||
? t("restrictedOffers", {
|
||||
count: preview.restrictedOfferCounts[selectedPage.id],
|
||||
})
|
||||
: t("emptyPage")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleOffers.map((offer) => {
|
||||
const ids = parsePreviewItemIds(offer.itemIds);
|
||||
const asset = assets.get(ids[0]);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={offer.id}
|
||||
aria-pressed={selectedOffer?.id === offer.id}
|
||||
onClick={() => setSelectedOfferId(offer.id)}
|
||||
className={`flex min-w-0 flex-col gap-2 rounded-lg border p-2 text-left ${selectedOffer?.id === offer.id ? "border-primary bg-primary/10" : "bg-card hover:bg-muted"}`}
|
||||
>
|
||||
<span className="flex h-20 w-full items-center justify-center">
|
||||
<PreviewIcon
|
||||
key={asset?.classname ?? `missing-${offer.id}`}
|
||||
asset={asset}
|
||||
missing={t("missingIcon")}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="w-full truncate text-xs font-medium"
|
||||
title={offer.catalogName}
|
||||
>
|
||||
{offer.catalogName || `#${offer.id}`}
|
||||
</span>
|
||||
<PreviewPrice offer={offer} />
|
||||
{offer.clubOnly === "1" && (
|
||||
<span className="text-[10px]">{t("clubOffer")}</span>
|
||||
)}
|
||||
{offer.amount > 1 && (
|
||||
<span className="text-[10px]">
|
||||
{t("amount", { count: offer.amount })}
|
||||
</span>
|
||||
)}
|
||||
{ids.length > 1 && (
|
||||
<span className="text-[10px]">
|
||||
{t("bundle", { count: ids.length })}
|
||||
</span>
|
||||
)}
|
||||
{offerChanged(offer, baseline) && (
|
||||
<span className="text-[10px] font-medium text-primary">
|
||||
{t("changed")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{lastIndex > 0 && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={index === 0}
|
||||
onClick={() => setPagination({ scope, index: index - 1 })}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<span className="text-xs">
|
||||
{t("page", { page: index + 1, total: lastIndex + 1 })}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={index === lastIndex}
|
||||
onClick={() => setPagination({ scope, index: index + 1 })}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{selectedOffer ? (
|
||||
<div
|
||||
className="space-y-2 rounded-lg border bg-muted/30 p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<h4 className="font-medium break-all">
|
||||
{selectedOffer.catalogName || `#${selectedOffer.id}`}
|
||||
</h4>
|
||||
<p className="text-xs">
|
||||
{t("offerId", { id: selectedOffer.id })} ·{" "}
|
||||
{t("amount", { count: selectedOffer.amount })}
|
||||
</p>
|
||||
<PreviewPrice offer={selectedOffer} />
|
||||
<ul className="text-xs text-muted-foreground">
|
||||
{parsePreviewItemIds(selectedOffer.itemIds).map((id) => (
|
||||
<li key={id}>
|
||||
{assets.get(id)?.name || t("unknownFurniture")} · #{id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{offerChanged(selectedOffer, baseline) && (
|
||||
<p className="text-xs">{t("changed")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
selectedPage && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("selectOffer")}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<PackageListRow[]>([]);
|
||||
const [categories, setCategories] = useState<
|
||||
Array<{ id: number; caption: string }>
|
||||
>([]);
|
||||
const [listQuery, setListQuery] = useState("");
|
||||
const [listPage, setListPage] = useState(0);
|
||||
const [saved, setSaved] = useState<CatalogPackage | null>(null);
|
||||
const [draft, setDraft] = useState<PackageSnapshot | null>(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<number[]>([]);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [tab, setTab] = useState<"edit" | "preview" | "review">("edit");
|
||||
const [previewBaseline, setPreviewBaseline] = useState(false);
|
||||
const [review, setReview] = useState<PackagePreview | null>(null);
|
||||
const [reviewPage, setReviewPage] = useState(0);
|
||||
const [publication, setPublication] = useState<PackagePublication | null>(
|
||||
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<void>) {
|
||||
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 (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => void start()}>
|
||||
{t("openPackages")}
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next || mayLeave()) {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setSaved(null);
|
||||
setDraft(null);
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex h-[90dvh] w-[calc(100vw-2rem)] max-w-7xl flex-col overflow-hidden border-[var(--admin-border)] bg-[var(--admin-surface)] text-[var(--admin-text)] sm:max-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="shrink-0 break-words text-sm text-[var(--admin-error)]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p role="status" className="shrink-0 text-sm">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{busy && (
|
||||
<p role="status" className="shrink-0 text-sm">
|
||||
{t("working")}
|
||||
</p>
|
||||
)}
|
||||
<fieldset
|
||||
disabled={busy}
|
||||
className="grid min-h-0 min-w-0 w-full flex-1 gap-4 overflow-y-auto lg:grid-cols-[16rem_minmax(0,1fr)]"
|
||||
>
|
||||
<aside className="space-y-3 lg:overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canEdit && (
|
||||
<Button onClick={newPackage}>{t("createPackage")}</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => void run(refreshList)}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
{t("searchPackages")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
value={listQuery}
|
||||
onChange={(event) => {
|
||||
setListQuery(event.target.value);
|
||||
setListPage(0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<ul className="space-y-2">
|
||||
{matches
|
||||
.slice(listPage * 20, (listPage + 1) * 20)
|
||||
.map((row) => (
|
||||
<li key={row.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={saved?.id === row.id}
|
||||
className={`w-full rounded border p-3 text-left ${saved?.id === row.id ? "border-[var(--admin-accent)]" : "border-[var(--admin-border)]"}`}
|
||||
onClick={() => void openPackage(row.id)}
|
||||
>
|
||||
<strong className="block break-words text-sm">
|
||||
{row.name}
|
||||
</strong>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t(row.status)} · {t(row.mode)} · v{row.version}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!matches.length && <p className="text-sm">{t("noPackages")}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!listPage}
|
||||
onClick={() => setListPage(listPage - 1)}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={(listPage + 1) * 20 >= matches.length}
|
||||
onClick={() => setListPage(listPage + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 space-y-4 lg:overflow-y-auto lg:pr-2">
|
||||
{creating && (
|
||||
<section className="space-y-4">
|
||||
<h3 className="font-semibold">{t("createPackage")}</h3>
|
||||
<label className="block text-sm">
|
||||
{t("name")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
maxLength={128}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("mode")}
|
||||
<select
|
||||
className="input mt-1 w-full"
|
||||
value={mode}
|
||||
onChange={(event) =>
|
||||
setMode(event.target.value as typeof mode)
|
||||
}
|
||||
>
|
||||
<option value="update">{t("update")}</option>
|
||||
<option value="copy">{t("copy")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
{t(mode === "copy" ? "copyHint" : "updateHint")}
|
||||
</p>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("sourceCategories")}
|
||||
</h4>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t("packageLimits")}
|
||||
</p>
|
||||
<PackageCategoryPicker
|
||||
pages={categories}
|
||||
selected={pageIds}
|
||||
onChange={setPageIds}
|
||||
multiple
|
||||
/>
|
||||
{mode === "copy" && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("targetParent")}
|
||||
</h4>
|
||||
<PackageCategoryPicker
|
||||
pages={categories}
|
||||
selected={[target]}
|
||||
onChange={(ids) => setTarget(ids[0])}
|
||||
root
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
disabled={!name.trim() || !pageIds.length}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
const result = await createCatalogPackage({
|
||||
name,
|
||||
pageIds,
|
||||
mode,
|
||||
targetParentId: mode === "copy" ? target : -1,
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
selectPackage(result.data);
|
||||
setNotice(t("draftCreated"));
|
||||
await refreshList();
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("createDraft")}
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
{!creating && !saved && (
|
||||
<p className="text-sm">{t("choosePackage")}</p>
|
||||
)}
|
||||
{saved && draft && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<strong>
|
||||
{t(saved.status)} · v{saved.version}
|
||||
</strong>
|
||||
<span>{t(saved.mode)}</span>
|
||||
{dirty && <span role="status">{t("unsaved")}</span>}
|
||||
</div>
|
||||
<fieldset disabled={!editable} className="space-y-3">
|
||||
<label className="block text-sm">
|
||||
{t("name")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
maxLength={128}
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
invalidate();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{saved.mode === "copy" && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
{t("targetParent")}:{" "}
|
||||
{categories.find((page) => page.id === target)
|
||||
?.caption ??
|
||||
(target === -1 ? t("rootCategory") : `#${target}`)}
|
||||
</summary>
|
||||
<PackageCategoryPicker
|
||||
pages={categories}
|
||||
selected={[target]}
|
||||
onChange={(ids) => {
|
||||
setTarget(ids[0]);
|
||||
invalidate();
|
||||
}}
|
||||
root
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
</fieldset>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={tab === "edit" ? "default" : "outline"}
|
||||
onClick={() => setTab("edit")}
|
||||
>
|
||||
{t("editDraft")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === "preview" ? "default" : "outline"}
|
||||
onClick={() => setTab("preview")}
|
||||
>
|
||||
{t("catalogPreview")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === "review" ? "default" : "outline"}
|
||||
onClick={() => setTab("review")}
|
||||
>
|
||||
{t("changes")}
|
||||
</Button>
|
||||
</div>
|
||||
{tab === "edit" && (
|
||||
<PackageSnapshotEditor
|
||||
key={saved.id}
|
||||
snapshot={draft}
|
||||
disabled={!editable}
|
||||
revision={revision}
|
||||
onChange={(value) => {
|
||||
setDraft(value);
|
||||
invalidate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tab === "preview" && (
|
||||
<section className="space-y-3">
|
||||
<label className="flex gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={previewBaseline}
|
||||
onChange={(event) =>
|
||||
setPreviewBaseline(event.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("showBaseline")}
|
||||
</label>
|
||||
<CatalogPackagePreview
|
||||
snapshot={previewBaseline ? saved.baseline : draft}
|
||||
baseline={previewBaseline ? undefined : saved.baseline}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{tab === "review" && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-semibold">{t("changes")}</h3>
|
||||
<p className="text-sm">
|
||||
{t(
|
||||
saved.mode === "copy" ? "copyReview" : "updateReview",
|
||||
)}
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">{t("entry")}</th>
|
||||
<th>{t("field")}</th>
|
||||
<th>{t("before")}</th>
|
||||
<th>{t("after")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes
|
||||
.slice(reviewPage * 20, (reviewPage + 1) * 20)
|
||||
.map((change) => (
|
||||
<tr
|
||||
key={change.key}
|
||||
className="border-t border-[var(--admin-border)]"
|
||||
>
|
||||
<th className="max-w-48 break-words p-2 text-left font-normal">
|
||||
{t(change.kind)} #{change.id} {change.label}
|
||||
</th>
|
||||
<td className="p-2">{t(change.field)}</td>
|
||||
<td className="max-w-48 break-words p-2">
|
||||
{change.before}
|
||||
</td>
|
||||
<td className="max-w-48 break-words p-2">
|
||||
{change.after}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!changes.length && (
|
||||
<p className="text-sm">{t("noChanges")}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!reviewPage}
|
||||
onClick={() => setReviewPage(reviewPage - 1)}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={(reviewPage + 1) * 20 >= changes.length}
|
||||
onClick={() => setReviewPage(reviewPage + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
{review && (
|
||||
<div className="space-y-2 rounded border border-[var(--admin-border)] p-3">
|
||||
<p>
|
||||
{t("publicationImpact", {
|
||||
pages: review.changedPages,
|
||||
offers: review.changedOffers,
|
||||
})}
|
||||
</p>
|
||||
{review.conflicts.length ? (
|
||||
<>
|
||||
<p role="alert">{t("conflicts")}</p>
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{[...new Set(review.conflicts)]
|
||||
.slice(0, 50)
|
||||
.map((conflict) => (
|
||||
<li key={conflict}>{conflict}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>{t("readyToPublish")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{publication?.warnings?.map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
role="status"
|
||||
className="text-sm text-[var(--admin-warning)]"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
{editable && (
|
||||
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!dirty}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("saveDraft")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={dirty}
|
||||
onClick={() => void requestReview()}
|
||||
>
|
||||
{t("reviewPublication")}
|
||||
</Button>
|
||||
{review && !review.conflicts.length && (
|
||||
<Button disabled={dirty} onClick={() => void publish()}>
|
||||
{t("publish")}
|
||||
</Button>
|
||||
)}
|
||||
<p className="w-full text-xs text-[var(--admin-text-muted)]">
|
||||
{t("saveHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<ReturnType<
|
||||
typeof packageBulkPrices
|
||||
> | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
function clear() {
|
||||
setPreview(null);
|
||||
setError("");
|
||||
setPage(0);
|
||||
}
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-3">
|
||||
<h4 className="font-medium">{t("bulkPrices", { count: ids.length })}</h4>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{t("bulkHint")}</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label>
|
||||
{t("field")}
|
||||
<select
|
||||
className="input block"
|
||||
value={field}
|
||||
onChange={(event) => {
|
||||
setField(event.target.value as typeof field);
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
<option value="costCredits">{t("costCredits")}</option>
|
||||
<option value="costPoints">{t("costPoints")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("operation")}
|
||||
<select
|
||||
className="input block"
|
||||
value={mode}
|
||||
onChange={(event) => {
|
||||
setMode(event.target.value as typeof mode);
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
<option value="set">{t("set")}</option>
|
||||
<option value="add">{t("add")}</option>
|
||||
<option value="percent">{t("percent")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("value")}
|
||||
<input
|
||||
type="number"
|
||||
step={mode === "percent" ? "any" : "1"}
|
||||
className="input block w-32"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
clear();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!ids.length}
|
||||
onClick={() => {
|
||||
try {
|
||||
const n = Number(value);
|
||||
if (
|
||||
!value.trim() ||
|
||||
!Number.isFinite(n) ||
|
||||
(mode !== "percent" && !Number.isInteger(n))
|
||||
)
|
||||
throw Error();
|
||||
setPreview(
|
||||
packageBulkPrices(offers, ids, { [field]: { mode, value: n } }),
|
||||
);
|
||||
setError("");
|
||||
setPage(0);
|
||||
} catch {
|
||||
setPreview(null);
|
||||
setError(t("invalidBulk"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("reviewBulk")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{preview && (
|
||||
<div className="space-y-3">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">{t("offer")}</th>
|
||||
<th>{t("before")}</th>
|
||||
<th>{t("after")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.slice(page * 20, (page + 1) * 20).map((row) => (
|
||||
<tr key={row.before.id}>
|
||||
<th className="max-w-64 break-words text-left font-normal">
|
||||
#{row.before.id} {row.before.catalogName}
|
||||
</th>
|
||||
<td className="text-center">{row.before[field]}</td>
|
||||
<td className="text-center">{row.after[field]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={(page + 1) * 20 >= preview.length}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const replacements = new Map(
|
||||
preview.map((row) => [row.after.id, row.after]),
|
||||
);
|
||||
onApply(
|
||||
offers.map((offer) => replacements.get(offer.id) ?? offer),
|
||||
);
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
{t("acceptBulk")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-2 rounded border border-[var(--admin-border)] p-3">
|
||||
<label className="block text-sm">
|
||||
{t("searchCategories")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{root && (
|
||||
<label className="flex gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(-1)}
|
||||
onChange={() => onChange([-1])}
|
||||
/>
|
||||
{t("rootCategory")}
|
||||
</label>
|
||||
)}
|
||||
<ul className="grid gap-1 sm:grid-cols-2">
|
||||
{matches.slice(page * 25, (page + 1) * 25).map((row) => (
|
||||
<li key={row.id}>
|
||||
<label className="flex gap-2 p-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
multiple
|
||||
? event.target.checked
|
||||
? [...selected, row.id]
|
||||
: selected.filter((id) => id !== row.id)
|
||||
: [row.id],
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
multiple &&
|
||||
selected.length >= 200 &&
|
||||
!selected.includes(row.id)
|
||||
}
|
||||
/>
|
||||
<span className="break-words">
|
||||
{row.caption}{" "}
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
#{row.id}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!page}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={(page + 1) * 25 >= matches.length}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
<span className="text-xs">
|
||||
{t("selectedCategories", { count: selected.length })}
|
||||
</span>
|
||||
{multiple && selected.length > 0 && (
|
||||
<Button variant="outline" onClick={() => onChange([])}>
|
||||
{t("clearSelection")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<PackageCategoryPicker
|
||||
pages={pages}
|
||||
selected={[]}
|
||||
onChange={() => {}}
|
||||
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(
|
||||
<PackageSnapshotEditor
|
||||
snapshot={snapshot}
|
||||
disabled
|
||||
revision={1}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(markup).toContain("Offer 25");
|
||||
expect(markup).not.toContain("Offer 26");
|
||||
expect(markup).toContain('<fieldset disabled=""');
|
||||
expect(markup).not.toContain("selectMatching");
|
||||
expect((markup.match(/aria-label="costCredits #[0-9]+"/g) ?? []).length).toBe(
|
||||
25,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type {
|
||||
PackageOffer,
|
||||
PackageSnapshot,
|
||||
} from "@/features/catalog/domain/packages";
|
||||
import {
|
||||
packageBulkPrices,
|
||||
packageChanges,
|
||||
packagePublicationIsCurrent,
|
||||
} from "./package-editor-helpers";
|
||||
|
||||
const offer: PackageOffer = {
|
||||
id: 1,
|
||||
pageId: 10,
|
||||
catalogName: "Chair",
|
||||
itemIds: "42",
|
||||
costCredits: 100,
|
||||
costPoints: 20,
|
||||
pointsType: 5,
|
||||
amount: 1,
|
||||
};
|
||||
it("previews only selected draft offers and preserves furniture references without mutating the snapshot", () => {
|
||||
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([]);
|
||||
});
|
||||
@@ -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<CatalogPackage, "status" | "version">,
|
||||
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;
|
||||
}
|
||||
@@ -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<number[]>([]);
|
||||
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<PackagePage>) {
|
||||
onChange({
|
||||
...snapshot,
|
||||
pages: snapshot.pages.map((row) =>
|
||||
row.id === selectedPage ? { ...row, ...patch } : row,
|
||||
),
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="grid gap-4 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm">
|
||||
{t("searchCategories")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
value={pageQuery}
|
||||
onChange={(event) => {
|
||||
setPageQuery(event.target.value);
|
||||
setCategoryPage(0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<ul className="space-y-1">
|
||||
{categories
|
||||
.slice(categoryPage * 25, (categoryPage + 1) * 25)
|
||||
.map((row) => (
|
||||
<li key={row.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={row.id === selectedPage}
|
||||
className={`w-full rounded border p-2 text-left text-sm ${row.id === selectedPage ? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10" : "border-[var(--admin-border)]"}`}
|
||||
onClick={() => {
|
||||
setSelectedPage(row.id);
|
||||
setOfferPage(0);
|
||||
}}
|
||||
>
|
||||
{row.caption}{" "}
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
#{row.id}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!categoryPage}
|
||||
onClick={() => setCategoryPage(categoryPage - 1)}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={(categoryPage + 1) * 25 >= categories.length}
|
||||
onClick={() => setCategoryPage(categoryPage + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{page && (
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className="min-w-0 space-y-3 rounded-lg border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
<legend className="px-2 font-medium">
|
||||
{t("category")} #{page.id}
|
||||
</legend>
|
||||
<label className="block text-sm">
|
||||
{t("caption")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
maxLength={128}
|
||||
required
|
||||
value={page.caption}
|
||||
onChange={(event) =>
|
||||
changePage({ caption: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(["iconImage", "minRank", "orderNum"] as const).map((field) => (
|
||||
<label key={field} className="text-sm">
|
||||
{t(field)}
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
className="input mt-1 w-full"
|
||||
value={numeric(page[field])}
|
||||
onChange={(event) =>
|
||||
changePage({ [field]: event.target.valueAsNumber })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="text-sm">
|
||||
{t("pageLayout")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
maxLength={26}
|
||||
required
|
||||
value={page.pageLayout}
|
||||
onChange={(event) =>
|
||||
changePage({ pageLayout: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{(["enabled", "visible", "clubOnly", "vipOnly"] as const).map(
|
||||
(field) => (
|
||||
<label
|
||||
key={field}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={page[field] === "1"}
|
||||
onChange={(event) =>
|
||||
changePage({
|
||||
[field]: event.target.checked ? "1" : "0",
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t(field)}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</section>
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-semibold">
|
||||
{t("offers", { count: offers.length })}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="min-w-48 flex-1 text-sm">
|
||||
{t("searchOffers")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
value={offerQuery}
|
||||
onChange={(event) => {
|
||||
setOfferQuery(event.target.value);
|
||||
setOfferPage(0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyCategory}
|
||||
onChange={(event) => {
|
||||
setOnlyCategory(event.target.checked);
|
||||
setOfferPage(0);
|
||||
}}
|
||||
/>
|
||||
{t("currentCategoryOffers")}
|
||||
</label>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedOffers(offers.map((row) => row.id))}
|
||||
>
|
||||
{t("selectMatching", { count: offers.length })}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSelectedOffers([])}>
|
||||
{t("clearSelection")}
|
||||
</Button>
|
||||
<span className="self-center text-sm">
|
||||
{t("selectedOffers", { count: selectedOffers.length })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{!disabled && <th>{t("select")}</th>}
|
||||
<th className="text-left">{t("offer")}</th>
|
||||
{(
|
||||
["costCredits", "costPoints", "pointsType", "amount"] as const
|
||||
).map((field) => (
|
||||
<th key={field}>{t(field)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{offers
|
||||
.slice(offerPage * 25, (offerPage + 1) * 25)
|
||||
.map((offer) => (
|
||||
<tr
|
||||
key={offer.id}
|
||||
className="border-t border-[var(--admin-border)]"
|
||||
>
|
||||
{!disabled && (
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t("selectOffer", { id: offer.id })}
|
||||
checked={selectedOffers.includes(offer.id)}
|
||||
onChange={(event) =>
|
||||
setSelectedOffers((current) =>
|
||||
event.target.checked
|
||||
? [...current, offer.id]
|
||||
: current.filter((id) => id !== offer.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<th className="max-w-56 break-words p-2 text-left font-normal">
|
||||
#{offer.id} {offer.catalogName}
|
||||
</th>
|
||||
{(
|
||||
[
|
||||
"costCredits",
|
||||
"costPoints",
|
||||
"pointsType",
|
||||
"amount",
|
||||
] as const
|
||||
).map((field) => (
|
||||
<td key={field} className="p-1">
|
||||
<input
|
||||
aria-label={`${t(field)} #${offer.id}`}
|
||||
className="input w-28"
|
||||
type="number"
|
||||
step="1"
|
||||
disabled={disabled}
|
||||
value={numeric(offer[field])}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...snapshot,
|
||||
offers: snapshot.offers.map((row) =>
|
||||
row.id === offer.id
|
||||
? {
|
||||
...row,
|
||||
[field]: event.target.valueAsNumber,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!offers.length && <p className="text-sm">{t("noOffers")}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!offerPage}
|
||||
onClick={() => setOfferPage(offerPage - 1)}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={(offerPage + 1) * 25 >= offers.length}
|
||||
onClick={() => setOfferPage(offerPage + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
{!disabled && selectedOffers.length > 0 && (
|
||||
<PackageBulkPrices
|
||||
key={`${revision}:${selectedOffers.join(",")}`}
|
||||
offers={snapshot.offers}
|
||||
ids={selectedOffers}
|
||||
onApply={(offers) => onChange({ ...snapshot, offers })}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof packagePageSchema>;
|
||||
export type PackageOffer = z.infer<typeof packageOfferSchema>;
|
||||
export type PackageSnapshot = z.infer<typeof packageSnapshotSchema>;
|
||||
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<typeof createPackageSchema>;
|
||||
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<typeof savePackageSchema>;
|
||||
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<typeof publishPackageSchema>;
|
||||
/** 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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import "server-only";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
"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<string> {
|
||||
const key = messages[message];
|
||||
if (!key) return message;
|
||||
const t = await getTranslations("pages.admin.catalogPackages.backend");
|
||||
return t(key);
|
||||
}
|
||||
@@ -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<Parameters<typeof db.transaction>[0]>[0];
|
||||
export type SourceRow = Record<string, unknown> & { 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<SourceRow[]> {
|
||||
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<PackageSource> {
|
||||
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<string, unknown>,
|
||||
mapping: Record<string, string>,
|
||||
): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(mapping).map(([key, column]) => [column, row[key]]),
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>[],
|
||||
offers: [] as Record<string, unknown>[],
|
||||
stored: {} as Record<string, string>,
|
||||
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<unknown>) => {
|
||||
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");
|
||||
});
|
||||
@@ -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<StoredPackage> {
|
||||
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<PackageListRow[]> {
|
||||
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<PackageListRow, "updatedAt"> & { updatedAt: Date | string }
|
||||
>
|
||||
).map((row) => ({
|
||||
...row,
|
||||
version: Number(row.version),
|
||||
updatedAt: new Date(row.updatedAt).toISOString(),
|
||||
}));
|
||||
}
|
||||
export async function getCatalogPackageCommand(
|
||||
id: string,
|
||||
): Promise<CatalogPackage> {
|
||||
return db.transaction(async (tx) => (await read(tx, id)).package);
|
||||
}
|
||||
export async function createCatalogPackageCommand(
|
||||
raw: CreateCatalogPackageInput,
|
||||
): Promise<CatalogPackage> {
|
||||
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<CatalogPackage> {
|
||||
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<PackagePreview> {
|
||||
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<string, unknown>,
|
||||
): Promise<number> {
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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<PackagePublication> {
|
||||
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<number, number>(),
|
||||
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<string, unknown> = {
|
||||
...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<PackagePublication> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user