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.
|
- 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.
|
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 Link from "@/components/link";
|
||||||
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||||
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
|
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 { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import {
|
import {
|
||||||
CatalogItems,
|
CatalogItems,
|
||||||
@@ -506,6 +507,7 @@ export default async function CatalogPagesPage({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||||
|
<CatalogPackagesDialog canEdit={canEdit} />
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<CatalogManagerDialog
|
<CatalogManagerDialog
|
||||||
catalogType="normal"
|
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",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"myDashboard": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Запазете елемента",
|
"save": "Запазете елемента",
|
||||||
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Uložit položku",
|
"save": "Uložit položku",
|
||||||
"saving": "Ukládání…",
|
"saving": "Ukládání…",
|
||||||
"cancel": "Zrušit"
|
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Save item",
|
"save": "Save item",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"cancel": "Annuller"
|
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1400,6 +1400,96 @@
|
|||||||
"save": "Artikel speichern",
|
"save": "Artikel speichern",
|
||||||
"saving": "Wird gespeichert…",
|
"saving": "Wird gespeichert…",
|
||||||
"cancel": "Abbrechen"
|
"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": {
|
"shop": {
|
||||||
@@ -3055,6 +3145,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"radioRequests": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Αποθήκευση στοιχείου",
|
"save": "Αποθήκευση στοιχείου",
|
||||||
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -2089,6 +2089,96 @@
|
|||||||
"selectionHint": "Changing the search or filter clears the selection.",
|
"selectionHint": "Changing the search or filter clears the selection.",
|
||||||
"noResults": "No offers match the current search and filter.",
|
"noResults": "No offers match the current search and filter.",
|
||||||
"selectVisible": "Select all visible offers"
|
"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": {
|
"shop": {
|
||||||
@@ -3881,6 +3971,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"mod": {
|
||||||
|
|||||||
@@ -1400,6 +1400,96 @@
|
|||||||
"save": "Guardar artículo",
|
"save": "Guardar artículo",
|
||||||
"saving": "Guardando…",
|
"saving": "Guardando…",
|
||||||
"cancel": "Cancelar"
|
"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": {
|
"shop": {
|
||||||
@@ -3055,6 +3145,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"radioRequests": {
|
||||||
|
|||||||
@@ -126,6 +126,161 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"myDashboard": {
|
||||||
|
|||||||
@@ -1400,6 +1400,96 @@
|
|||||||
"save": "Enregistrer l'article",
|
"save": "Enregistrer l'article",
|
||||||
"saving": "Enregistrement…",
|
"saving": "Enregistrement…",
|
||||||
"cancel": "Annuler"
|
"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": {
|
"shop": {
|
||||||
@@ -3055,6 +3145,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"radioRequests": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Spremi stavku",
|
"save": "Spremi stavku",
|
||||||
"saving": "Spremanje...",
|
"saving": "Spremanje...",
|
||||||
"cancel": "Odustani"
|
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Mentse el az elemet",
|
"save": "Mentse el az elemet",
|
||||||
"saving": "Mentés…",
|
"saving": "Mentés…",
|
||||||
"cancel": "Mégse"
|
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -2008,6 +2008,96 @@
|
|||||||
"selectionHint": "Cambiare ricerca o filtro azzera la selezione.",
|
"selectionHint": "Cambiare ricerca o filtro azzera la selezione.",
|
||||||
"noResults": "Nessuna offerta corrisponde alla ricerca e al filtro attuali.",
|
"noResults": "Nessuna offerta corrisponde alla ricerca e al filtro attuali.",
|
||||||
"selectVisible": "Seleziona tutte le offerte visibili"
|
"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": {
|
"shop": {
|
||||||
@@ -3856,6 +3946,69 @@
|
|||||||
"publications": "Bozze delle news",
|
"publications": "Bozze delle news",
|
||||||
"unavailable": "{source}: dati temporaneamente non disponibili.",
|
"unavailable": "{source}: dati temporaneamente non disponibili.",
|
||||||
"installation": "Diagnostica installazione"
|
"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": {
|
"radioRequests": {
|
||||||
|
|||||||
@@ -126,6 +126,161 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"myDashboard": {
|
||||||
|
|||||||
@@ -1838,6 +1838,96 @@
|
|||||||
"selectionHint": "De selectie wordt gewist wanneer je de zoekopdracht of het filter wijzigt.",
|
"selectionHint": "De selectie wordt gewist wanneer je de zoekopdracht of het filter wijzigt.",
|
||||||
"noResults": "Geen aanbiedingen passen bij de huidige zoekopdracht en het filter.",
|
"noResults": "Geen aanbiedingen passen bij de huidige zoekopdracht en het filter.",
|
||||||
"selectVisible": "Alle zichtbare aanbiedingen selecteren"
|
"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": {
|
"shop": {
|
||||||
@@ -3430,6 +3520,69 @@
|
|||||||
"publications": "Nieuwsconcepten",
|
"publications": "Nieuwsconcepten",
|
||||||
"unavailable": "{source}: gegevens tijdelijk niet beschikbaar.",
|
"unavailable": "{source}: gegevens tijdelijk niet beschikbaar.",
|
||||||
"installation": "Installatiediagnose"
|
"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": {
|
"mod": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Lagre element",
|
"save": "Lagre element",
|
||||||
"saving": "Lagrer …",
|
"saving": "Lagrer …",
|
||||||
"cancel": "Avbryt"
|
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1430,6 +1430,96 @@
|
|||||||
"save": "Zapisz element",
|
"save": "Zapisz element",
|
||||||
"saving": "Zapisuję…",
|
"saving": "Zapisuję…",
|
||||||
"cancel": "Anuluj"
|
"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": {
|
"shop": {
|
||||||
@@ -3085,6 +3175,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1430,6 +1430,96 @@
|
|||||||
"save": "Salvar item",
|
"save": "Salvar item",
|
||||||
"saving": "Salvando…",
|
"saving": "Salvando…",
|
||||||
"cancel": "Cancelar"
|
"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": {
|
"shop": {
|
||||||
@@ -3085,6 +3175,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Salvați elementul",
|
"save": "Salvați elementul",
|
||||||
"saving": "Se salvează…",
|
"saving": "Se salvează…",
|
||||||
"cancel": "Anulează"
|
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1428,6 +1428,96 @@
|
|||||||
"save": "Save item",
|
"save": "Save item",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"cancel": "Cancel"
|
"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": {
|
"shop": {
|
||||||
@@ -3083,6 +3173,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Uložiť položku",
|
"save": "Uložiť položku",
|
||||||
"saving": "Ukladá sa…",
|
"saving": "Ukladá sa…",
|
||||||
"cancel": "Zrušiť"
|
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Сачувај ставку",
|
"save": "Сачувај ставку",
|
||||||
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Spara objekt",
|
"save": "Spara objekt",
|
||||||
"saving": "Sparar...",
|
"saving": "Sparar...",
|
||||||
"cancel": "Avbryt"
|
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1430,6 +1430,96 @@
|
|||||||
"save": "Öğeyi kaydet",
|
"save": "Öğeyi kaydet",
|
||||||
"saving": "Kaydediliyor…",
|
"saving": "Kaydediliyor…",
|
||||||
"cancel": "İptal"
|
"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": {
|
"shop": {
|
||||||
@@ -3085,6 +3175,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -1429,6 +1429,96 @@
|
|||||||
"save": "Зберегти товар",
|
"save": "Зберегти товар",
|
||||||
"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": {
|
"shop": {
|
||||||
@@ -3084,6 +3174,69 @@
|
|||||||
"publications": "News drafts",
|
"publications": "News drafts",
|
||||||
"unavailable": "{source}: data is temporarily unavailable.",
|
"unavailable": "{source}: data is temporarily unavailable.",
|
||||||
"installation": "Installation diagnostics"
|
"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": {
|
"error": {
|
||||||
|
|||||||
Reference in new issue
Block a user