feat(catalog): add saved packages, preview and bulk price editing

This commit is contained in:
Simo committed 2026-09-09 20:04:10 +02:00
1 parent 867113d5d4
commit 047cd9f3dd
48 files changed
+7712

No files matched your search

+35
View File
@@ -46,3 +46,38 @@ Local verification on 2026-09-09:
- Deployment rollback, verified-digest publication, ownership/cancellation and concurrent news edit behavior have focused regression tests.
Docker runtime, database migration execution and authenticated browser flows still require an integration environment. Check the Gitea pipeline and live release identifier after publication. A successful local build is not production verification.
## Catalog packages
The normal catalog toolbar now opens a dedicated Catalog packages dialog.
Create a named draft from selected categories and their descendants, either to
update those categories or copy them under a chosen parent. Drafts are shared
with authorized staff; saving a draft does not modify the live catalog.
Edit category metadata and offer prices, or review bulk price changes before
applying them to the draft. The catalog preview supports category navigation,
search, real local furniture icons and rank/Club/VIP access simulation. It does
not render the game client or evaluate ancestors outside the selected package;
special layouts and that access limitation are disclosed in the preview.
Publication requires a saved draft and a fresh review. Concurrent source changes
block publication; version checks prevent one editor overwriting another.
Copying preserves the underlying category and offer fields and remaps internal
references while retaining furniture IDs and assets. The catalog writes and
published result are committed together; retrying the same published package
does not copy it again. Failures in hotel notifications, audit or Git export
scheduling after commit are reported as warnings rather than failed publication.
Apply additive migration `0027_catalog_packages.sql` before opening this tool.
Existing migration automation discovers the file. Limits are 200 categories,
500 offers and 8 MB of package data. Package source checks inspect at most 20,000
catalog categories. Publication briefly locks category rows while validating and
writing changes, so large live catalogs should be checked under realistic load.
English, Italian and Dutch copy is provided; other locales use the new English
strings pending translation. No new dependency is required.
Validation: 1,506 tests passed (six skipped), type checking, lint, translation
contracts and a production build with fixture configuration. A browser fixture
verified the real dialog at 1280 and 390 pixels; server actions were simulated.
The new database migration and package publication have not run in production.
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS website_catalog_packages (
id VARCHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
version INT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL,
mode VARCHAR(16) NOT NULL,
payload LONGTEXT NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX package_recent (updated_at, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+156
View File
@@ -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();
});
+123
View File
@@ -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();
});
});
+31
View File
@@ -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: [] };
}
}
+2
View File
@@ -19,6 +19,7 @@ import { CatalogTree } from "@/components/admin/catalog-tree";
import Link from "@/components/link";
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
import { CatalogPackagesDialog } from "@/features/catalog/components/packages/catalog-packages-dialog";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
CatalogItems,
@@ -506,6 +507,7 @@ export default async function CatalogPagesPage({
</p>
<div className="flex flex-wrap items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
<CatalogPackagesDialog canEdit={canEdit} />
{canEdit && (
<CatalogManagerDialog
catalogType="normal"
+16
View File
@@ -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>
);
}
+146
View File
@@ -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");
});
+428
View File
@@ -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;
}
}
}
+155
View File
@@ -126,6 +126,161 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalog": {
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"myDashboard": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Запазете елемента",
"saving": "Запазва се...",
"cancel": "Отказ"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Uložit položku",
"saving": "Ukládání…",
"cancel": "Zrušit"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Save item",
"saving": "Saving…",
"cancel": "Annuller"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1400,6 +1400,96 @@
"save": "Artikel speichern",
"saving": "Wird gespeichert…",
"cancel": "Abbrechen"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3055,6 +3145,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"radioRequests": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Αποθήκευση στοιχείου",
"saving": "Αποθήκευση…",
"cancel": "Ακύρωση"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -2089,6 +2089,96 @@
"selectionHint": "Changing the search or filter clears the selection.",
"noResults": "No offers match the current search and filter.",
"selectVisible": "Select all visible offers"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3881,6 +3971,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"mod": {
+153
View File
@@ -1400,6 +1400,96 @@
"save": "Guardar artículo",
"saving": "Guardando…",
"cancel": "Cancelar"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3055,6 +3145,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"radioRequests": {
+155
View File
@@ -126,6 +126,161 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalog": {
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"myDashboard": {
+153
View File
@@ -1400,6 +1400,96 @@
"save": "Enregistrer l'article",
"saving": "Enregistrement…",
"cancel": "Annuler"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3055,6 +3145,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"radioRequests": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Spremi stavku",
"saving": "Spremanje...",
"cancel": "Odustani"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Mentse el az elemet",
"saving": "Mentés…",
"cancel": "Mégse"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -2008,6 +2008,96 @@
"selectionHint": "Cambiare ricerca o filtro azzera la selezione.",
"noResults": "Nessuna offerta corrisponde alla ricerca e al filtro attuali.",
"selectVisible": "Seleziona tutte le offerte visibili"
},
"packages": {
"openPackages": "Pacchetti catalogo",
"title": "Pacchetti catalogo",
"description": "Prepara modifiche a categorie e prezzi in una bozza separata. Controlla il catalogo prima di pubblicare.",
"createPackage": "Nuovo pacchetto",
"createDraft": "Crea bozza",
"draftCreated": "Bozza del pacchetto creata. Il catalogo live non è stato modificato.",
"refresh": "Aggiorna elenco",
"searchPackages": "Cerca pacchetti",
"noPackages": "Nessun pacchetto corrisponde alla ricerca.",
"choosePackage": "Apri un pacchetto o crea una bozza dalle categorie esistenti.",
"name": "Nome pacchetto",
"mode": "Modalità pubblicazione",
"update": "Aggiorna categorie esistenti",
"copy": "Crea copie",
"updateHint": "La pubblicazione applica il pacchetto alle categorie e offerte esistenti.",
"copyHint": "La pubblicazione crea nuove categorie e offerte sotto il genitore scelto. I riferimenti ai furni vengono mantenuti.",
"sourceCategories": "Categorie di origine",
"packageLimits": "Sono incluse le sottocategorie. Ogni pacchetto supporta fino a 200 categorie e 500 offerte.",
"searchCategories": "Cerca categorie",
"selectedCategories": "Selezionate: {count}",
"targetParent": "Categoria genitore di destinazione",
"rootCategory": "Radice del catalogo",
"draft": "Bozza",
"published": "Pubblicato",
"editDraft": "Modifica bozza",
"catalogPreview": "Anteprima catalogo",
"showBaseline": "Mostra l’istantanea originale salvata",
"changes": "Prima e dopo",
"entry": "Voce",
"field": "Campo",
"before": "Prima",
"after": "Dopo",
"noChanges": "Nessun campo modificato rispetto all’istantanea originale.",
"copyReview": "Alla pubblicazione sarà copiato l’intero pacchetto, anche senza campi modificati. La tabella elenca le modifiche all’istantanea originale.",
"updateReview": "Queste modifiche della bozza aggiorneranno le voci esistenti dopo la pubblicazione.",
"saveDraft": "Salva bozza",
"saveHint": "Il salvataggio mantiene il pacchetto come bozza. Salva tutte le modifiche prima di richiedere la verifica di pubblicazione.",
"draftSaved": "Bozza salvata. La pubblicazione è un passaggio separato.",
"reviewPublication": "Verifica pubblicazione",
"publicationImpact": "La pubblicazione interessa {pages} categorie e {offers} offerte.",
"conflicts": "La pubblicazione è bloccata da conflitti. Risolvili prima di richiedere una nuova verifica.",
"readyToPublish": "La versione salvata ha superato la verifica. La pubblicazione modifica il catalogo live.",
"publish": "Pubblica pacchetto",
"confirmPublish": "Pubblicare ora questo pacchetto verificato nel catalogo live?",
"publishedSuccess": "Pacchetto pubblicato: {pages} categorie e {offers} offerte.",
"staleReview": "Questo pacchetto è cambiato in un’altra sessione. Riaprilo per verificare l’ultima versione salvata.",
"unsaved": "Modifiche non salvate",
"discardChanges": "Scartare le modifiche non salvate al pacchetto e continuare?",
"working": "Operazione in corso…",
"requestFailed": "La richiesta non è riuscita. Riprova.",
"invalidFields": "Controlla nomi obbligatori, layout e campi numerici interi prima di salvare.",
"category": "Categoria",
"caption": "Nome categoria",
"iconImage": "ID icona categoria",
"minRank": "Rango minimo",
"orderNum": "Ordine di visualizzazione",
"pageLayout": "Layout pagina",
"enabled": "Abilitata",
"visible": "Visibile",
"clubOnly": "Solo membri Club",
"vipOnly": "Solo membri VIP",
"offers": "Offerte ({count})",
"offer": "Offerta",
"catalogName": "Nome offerta",
"costCredits": "Crediti",
"costPoints": "Punti",
"pointsType": "ID valuta punti",
"amount": "Quantità",
"searchOffers": "Cerca offerte",
"currentCategoryOffers": "Solo la categoria selezionata",
"selectMatching": "Seleziona tutte le offerte filtrate ({count})",
"clearSelection": "Svuota selezione",
"selectedOffers": "Offerte selezionate: {count}",
"select": "Seleziona",
"selectOffer": "Seleziona offerta #{id}",
"noOffers": "Nessuna offerta corrisponde ai filtri.",
"bulkPrices": "Prezzi della bozza in blocco ({count} selezionate)",
"bulkHint": "Controlla i prezzi selezionati, poi accettali nella bozza. Il catalogo live resta invariato fino alla pubblicazione.",
"operation": "Operazione",
"set": "Imposta valore",
"add": "Aggiungi o sottrai",
"percent": "Variazione percentuale",
"value": "Valore",
"reviewBulk": "Anteprima prezzi",
"invalidBulk": "Scegli numeri validi e almeno un’offerta. I prezzi risultanti devono essere non negativi e nell’intervallo supportato.",
"acceptBulk": "Accetta prezzi nella bozza",
"previous": "Precedente",
"next": "Successiva"
}
},
"shop": {
@@ -3856,6 +3946,69 @@
"publications": "Bozze delle news",
"unavailable": "{source}: dati temporaneamente non disponibili.",
"installation": "Diagnostica installazione"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Le categorie o le offerte sorgenti sono cambiate. Crea un nuovo pacchetto per includere i dati aggiornati.",
"saveConflict": "Il pacchetto è cambiato. Ricaricalo prima di salvare.",
"publishConflict": "Il pacchetto è cambiato. Aggiorna l’anteprima di pubblicazione.",
"catalogChanged": "Il catalogo è cambiato. Aggiorna l’anteprima di pubblicazione.",
"missing": "Pacchetto catalogo non trovato.",
"pageLimit": "Un pacchetto supporta al massimo 200 categorie, incluse le sottocategorie.",
"offerLimit": "Un pacchetto supporta al massimo 500 offerte.",
"payloadLimit": "I dati del pacchetto superano il limite di 8 MB.",
"membership": "Non puoi aggiungere o rimuovere elementi dal pacchetto.",
"hierarchy": "Non puoi modificare la gerarchia del pacchetto.",
"references": "Non puoi modificare i riferimenti ai furni del pacchetto.",
"furnitureMissing": "Un furni referenziato dal pacchetto non esiste più.",
"auditWarning": "Pacchetto pubblicato, ma la registrazione nell’audit non è riuscita.",
"hotelWarning": "Pacchetto pubblicato, ma l’aggiornamento non è stato inviato all’hotel.",
"exportWarning": "Pacchetto pubblicato, ma la pianificazione dell’esportazione non è riuscita.",
"refreshWarning": "Pacchetto pubblicato. Aggiorna la pagina per vedere il catalogo aggiornato."
}
},
"catalogPackagePreview": {
"title": "Anteprima catalogo",
"hint": "Sfoglia le pagine e le offerte reali del pacchetto. Questa è un’anteprima del catalogo, non la riproduzione completa del client di gioco. Non vengono effettuati acquisti.",
"search": "Cerca categorie o offerte",
"playerMode": "Accesso giocatore",
"rank": "Rango",
"club": "Club",
"vip": "VIP",
"externalParents": "La selezione appartiene a un catalogo più ampio. Non è possibile valutare le restrizioni delle categorie superiori esterne a questa istantanea.",
"invalidHierarchy": "{count} pagine hanno una gerarchia ciclica e non sono navigabili.",
"snapshotLimit": "Anteprima limitata a 200 pagine e 500 offerte.",
"restricted": "{count} pagine sono nascoste dalle impostazioni di accesso selezionate.",
"categories": "Categorie del catalogo",
"noPages": "Questa istantanea non contiene categorie.",
"inaccessible": "Nessuna categoria è accessibile con queste impostazioni. Modifica l’accesso giocatore o disattivalo per esaminare la bozza.",
"noMatches": "Nessun risultato per questa ricerca.",
"offerCount": "{count} offerte",
"layout": "Layout: {layout}",
"missingLayout": "Non specificato",
"unsupportedLayout": "Questo layout non ha un’anteprima dedicata. La griglia mostra le offerte reali; componenti e immagini del layout personalizzato non vengono riprodotti.",
"loadingIcons": "Caricamento delle icone dei furni…",
"assetsUnavailable": "I dettagli dei furni non sono al momento disponibili. I dati delle offerte restano visibili.",
"assetLimit": "Icone e nomi dei furni sono limitati ai primi 200 ID di questa vista.",
"emptyPage": "Questa categoria non contiene offerte dirette. Sfoglia le sottocategorie.",
"missingIcon": "Icona non disponibile",
"amount": "Quantità: {count}",
"bundle": "{count} tipi di furni",
"changed": "Modificato rispetto alla versione iniziale salvata",
"previous": "Precedente",
"next": "Successiva",
"page": "Pagina {page} di {total}",
"offerId": "Offerta #{id}",
"unknownFurniture": "Nome del furni non disponibile",
"selectOffer": "Seleziona un’offerta per esaminare il prezzo e i furni inclusi.",
"credits": "Crediti",
"duckets": "Duckets",
"diamonds": "Diamanti",
"pointsType": "Punti (tipo {type}):",
"free": "Gratis",
"specialPricing": "Prezzo speciale: {credits} crediti, {points} punti (tipo {type})",
"restrictedOffers": "{count} offerte in questa categoria richiedono l’iscrizione al Club.",
"clubOffer": "Offerta Club"
}
},
"radioRequests": {
+155
View File
@@ -126,6 +126,161 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalog": {
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"myDashboard": {
+153
View File
@@ -1838,6 +1838,96 @@
"selectionHint": "De selectie wordt gewist wanneer je de zoekopdracht of het filter wijzigt.",
"noResults": "Geen aanbiedingen passen bij de huidige zoekopdracht en het filter.",
"selectVisible": "Alle zichtbare aanbiedingen selecteren"
},
"packages": {
"openPackages": "Cataloguspakketten",
"title": "Cataloguspakketten",
"description": "Bereid categorie- en prijswijzigingen voor in een apart concept. Controleer de catalogus voordat je publiceert.",
"createPackage": "Nieuw pakket",
"createDraft": "Concept maken",
"draftCreated": "Pakketconcept gemaakt. De livecatalogus is niet gewijzigd.",
"refresh": "Lijst vernieuwen",
"searchPackages": "Pakketten zoeken",
"noPackages": "Geen pakketten gevonden.",
"choosePackage": "Open een pakket of maak een concept op basis van bestaande categorieën.",
"name": "Pakketnaam",
"mode": "Publicatiemodus",
"update": "Bestaande categorieën bijwerken",
"copy": "Kopieën maken",
"updateHint": "Bij publicatie worden de bestaande categorieën en aanbiedingen bijgewerkt.",
"copyHint": "Bij publicatie worden nieuwe categorieën en aanbiedingen onder de gekozen bovenliggende categorie gemaakt. Meubelverwijzingen blijven behouden.",
"sourceCategories": "Broncategorieën",
"packageLimits": "Subcategorieën worden meegenomen. Elk pakket ondersteunt maximaal 200 categorieën en 500 aanbiedingen.",
"searchCategories": "Categorieën zoeken",
"selectedCategories": "Geselecteerd: {count}",
"targetParent": "Bovenliggende doelcategorie",
"rootCategory": "Hoofdniveau van de catalogus",
"draft": "Concept",
"published": "Gepubliceerd",
"editDraft": "Concept bewerken",
"catalogPreview": "Catalogusvoorbeeld",
"showBaseline": "Oorspronkelijke opgeslagen momentopname tonen",
"changes": "Voor en na",
"entry": "Onderdeel",
"field": "Veld",
"before": "Voor",
"after": "Na",
"noChanges": "Geen velden gewijzigd ten opzichte van de oorspronkelijke momentopname.",
"copyReview": "Het volledige pakket wordt bij publicatie gekopieerd, ook zonder veldwijzigingen. De tabel toont wijzigingen ten opzichte van de oorspronkelijke momentopname.",
"updateReview": "Deze conceptwijzigingen worden na publicatie op bestaande catalogusonderdelen toegepast.",
"saveDraft": "Concept opslaan",
"saveHint": "Opslaan bewaart het pakket als concept. Sla alle wijzigingen op voordat je een publicatiecontrole aanvraagt.",
"draftSaved": "Concept opgeslagen. Publiceren is een aparte stap.",
"reviewPublication": "Publicatie controleren",
"publicationImpact": "Publicatie betreft {pages} categorieën en {offers} aanbiedingen.",
"conflicts": "Publicatie wordt geblokkeerd door conflicten. Los deze op voordat je opnieuw controleert.",
"readyToPublish": "De opgeslagen versie heeft de publicatiecontrole doorstaan. Publiceren wijzigt de livecatalogus.",
"publish": "Pakket publiceren",
"confirmPublish": "Dit gecontroleerde pakket nu publiceren in de livecatalogus?",
"publishedSuccess": "Pakket gepubliceerd: {pages} categorieën en {offers} aanbiedingen.",
"staleReview": "Dit pakket is in een andere sessie gewijzigd. Open het opnieuw om de laatste opgeslagen versie te controleren.",
"unsaved": "Niet-opgeslagen wijzigingen",
"discardChanges": "Niet-opgeslagen pakketwijzigingen verwerpen en doorgaan?",
"working": "Bezig…",
"requestFailed": "Het verzoek is mislukt. Probeer het opnieuw.",
"invalidFields": "Controleer verplichte namen, lay-outs en gehele getallen voordat je opslaat.",
"category": "Categorie",
"caption": "Categorienaam",
"iconImage": "Categoriepictogram-ID",
"minRank": "Minimale rang",
"orderNum": "Weergavevolgorde",
"pageLayout": "Paginalay-out",
"enabled": "Ingeschakeld",
"visible": "Zichtbaar",
"clubOnly": "Alleen Clubleden",
"vipOnly": "Alleen VIP-leden",
"offers": "Aanbiedingen ({count})",
"offer": "Aanbieding",
"catalogName": "Aanbiedingsnaam",
"costCredits": "Credits",
"costPoints": "Punten",
"pointsType": "Puntenvaluta-ID",
"amount": "Aantal",
"searchOffers": "Aanbiedingen zoeken",
"currentCategoryOffers": "Alleen de geselecteerde categorie",
"selectMatching": "Alle overeenkomende aanbiedingen selecteren ({count})",
"clearSelection": "Selectie wissen",
"selectedOffers": "Geselecteerde aanbiedingen: {count}",
"select": "Selecteren",
"selectOffer": "Aanbieding #{id} selecteren",
"noOffers": "Geen aanbiedingen gevonden met deze filters.",
"bulkPrices": "Conceptprijzen in bulk ({count} geselecteerd)",
"bulkHint": "Bekijk de geselecteerde prijswijzigingen en neem ze daarna over in dit concept. De livecatalogus blijft ongewijzigd tot publicatie.",
"operation": "Bewerking",
"set": "Waarde instellen",
"add": "Optellen of aftrekken",
"percent": "Procentueel aanpassen",
"value": "Waarde",
"reviewBulk": "Prijsvoorbeeld",
"invalidBulk": "Kies geldige getallen en minstens één aanbieding. De resulterende prijzen mogen niet negatief zijn en moeten binnen het ondersteunde bereik vallen.",
"acceptBulk": "Prijzen overnemen in concept",
"previous": "Vorige",
"next": "Volgende"
}
},
"shop": {
@@ -3430,6 +3520,69 @@
"publications": "Nieuwsconcepten",
"unavailable": "{source}: gegevens tijdelijk niet beschikbaar.",
"installation": "Installatiediagnose"
},
"catalogPackages": {
"backend": {
"sourceChanged": "De broncategorieën of aanbiedingen zijn gewijzigd. Maak een nieuw pakket met de actuele gegevens.",
"saveConflict": "Het pakket is gewijzigd. Laad het opnieuw voordat je opslaat.",
"publishConflict": "Het pakket is gewijzigd. Vernieuw het publicatievoorbeeld.",
"catalogChanged": "De catalogus is gewijzigd. Vernieuw het publicatievoorbeeld.",
"missing": "Cataloguspakket niet gevonden.",
"pageLimit": "Een pakket ondersteunt maximaal 200 categorieën, inclusief subcategorieën.",
"offerLimit": "Een pakket ondersteunt maximaal 500 aanbiedingen.",
"payloadLimit": "De pakketgegevens overschrijden de limiet van 8 MB.",
"membership": "Je kunt geen onderdelen aan het pakket toevoegen of eruit verwijderen.",
"hierarchy": "Je kunt de pakkethiërarchie niet wijzigen.",
"references": "Je kunt de meubelverwijzingen in het pakket niet wijzigen.",
"furnitureMissing": "Een meubel waarnaar het pakket verwijst bestaat niet meer.",
"auditWarning": "Pakket gepubliceerd, maar het schrijven naar het auditlog is mislukt.",
"hotelWarning": "Pakket gepubliceerd, maar de hotelupdate is niet afgeleverd.",
"exportWarning": "Pakket gepubliceerd, maar het plannen van de export is mislukt.",
"refreshWarning": "Pakket gepubliceerd. Vernieuw de pagina om de nieuwste catalogus te zien."
}
},
"catalogPackagePreview": {
"title": "Catalogusvoorbeeld",
"hint": "Blader door de echte pagina’s en aanbiedingen van het pakket. Dit is een catalogusvoorbeeld, geen volledige weergave van de spelclient. Er worden geen aankopen gedaan.",
"search": "Categorieën of aanbiedingen zoeken",
"playerMode": "Spelerstoegang",
"rank": "Rang",
"club": "Club",
"vip": "VIP",
"externalParents": "Deze selectie is onderdeel van een grotere catalogus. Toegangsregels van bovenliggende categorieën buiten deze momentopname kunnen niet worden beoordeeld.",
"invalidHierarchy": "{count} pagina’s hebben een cyclische hiërarchie en kunnen niet worden geopend.",
"snapshotLimit": "Voorbeeld beperkt tot 200 pagina’s en 500 aanbiedingen.",
"restricted": "{count} pagina’s zijn verborgen door de gekozen toegangsinstellingen.",
"categories": "Cataloguscategorieën",
"noPages": "Deze momentopname bevat geen categorieën.",
"inaccessible": "Met deze instellingen zijn geen categorieën toegankelijk. Pas de spelerstoegang aan of schakel deze uit om het concept te bekijken.",
"noMatches": "Geen resultaten voor deze zoekopdracht.",
"offerCount": "{count} aanbiedingen",
"layout": "Indeling: {layout}",
"missingLayout": "Niet opgegeven",
"unsupportedLayout": "Deze indeling heeft geen eigen voorbeeld. Het raster toont de echte aanbiedingen; aangepaste onderdelen en afbeeldingen worden niet weergegeven.",
"loadingIcons": "Meubelpictogrammen laden…",
"assetsUnavailable": "Meubelgegevens zijn tijdelijk niet beschikbaar. De aanbiedingen blijven zichtbaar.",
"assetLimit": "Pictogrammen en meubelnamen zijn beperkt tot de eerste 200 item-ID’s in deze weergave.",
"emptyPage": "Deze categorie bevat geen directe aanbiedingen. Bekijk de subcategorieën.",
"missingIcon": "Pictogram niet beschikbaar",
"amount": "Aantal: {count}",
"bundle": "{count} meubeltypes",
"changed": "Gewijzigd ten opzichte van de opgeslagen beginversie",
"previous": "Vorige",
"next": "Volgende",
"page": "Pagina {page} van {total}",
"offerId": "Aanbieding #{id}",
"unknownFurniture": "Meubelnaam niet beschikbaar",
"selectOffer": "Selecteer een aanbieding om de prijs en meubelverwijzingen te bekijken.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamanten",
"pointsType": "Punten (type {type}):",
"free": "Gratis",
"specialPricing": "Speciale prijs: {credits} credits, {points} punten (type {type})",
"restrictedOffers": "{count} aanbiedingen in deze categorie vereisen een Club-lidmaatschap.",
"clubOffer": "Club-aanbieding"
}
},
"mod": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Lagre element",
"saving": "Lagrer …",
"cancel": "Avbryt"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1430,6 +1430,96 @@
"save": "Zapisz element",
"saving": "Zapisuję…",
"cancel": "Anuluj"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3085,6 +3175,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1430,6 +1430,96 @@
"save": "Salvar item",
"saving": "Salvando…",
"cancel": "Cancelar"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3085,6 +3175,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Salvați elementul",
"saving": "Se salvează…",
"cancel": "Anulează"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1428,6 +1428,96 @@
"save": "Save item",
"saving": "Saving…",
"cancel": "Cancel"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3083,6 +3173,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Uložiť položku",
"saving": "Ukladá sa…",
"cancel": "Zrušiť"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Сачувај ставку",
"saving": "Чување…",
"cancel": "Откажи"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Spara objekt",
"saving": "Sparar...",
"cancel": "Avbryt"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1430,6 +1430,96 @@
"save": "Öğeyi kaydet",
"saving": "Kaydediliyor…",
"cancel": "İptal"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3085,6 +3175,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {
+153
View File
@@ -1429,6 +1429,96 @@
"save": "Зберегти товар",
"saving": "Збереження…",
"cancel": "Скасувати"
},
"packages": {
"openPackages": "Catalog packages",
"title": "Catalog packages",
"description": "Prepare category and price changes in a separate draft. Review the catalog before publishing.",
"createPackage": "New package",
"createDraft": "Create draft",
"draftCreated": "Package draft created. The live catalog has not changed.",
"refresh": "Refresh list",
"searchPackages": "Search packages",
"noPackages": "No packages match this search.",
"choosePackage": "Open a package or create a draft from existing categories.",
"name": "Package name",
"mode": "Publication mode",
"update": "Update existing categories",
"copy": "Create copies",
"updateHint": "Publication applies the package to its existing categories and offers.",
"copyHint": "Publication creates new categories and offers under the selected parent. Furniture references are preserved.",
"sourceCategories": "Source categories",
"packageLimits": "Descendant categories are included. Each package supports up to 200 categories and 500 offers.",
"searchCategories": "Search categories",
"selectedCategories": "Selected: {count}",
"targetParent": "Destination parent category",
"rootCategory": "Catalog root",
"draft": "Draft",
"published": "Published",
"editDraft": "Edit draft",
"catalogPreview": "Catalog preview",
"showBaseline": "Show the original saved snapshot",
"changes": "Before and after",
"entry": "Entry",
"field": "Field",
"before": "Before",
"after": "After",
"noChanges": "No edited fields compared with the original snapshot.",
"copyReview": "The entire package will be copied on publication, even if no fields were edited. The table lists edits to the original snapshot.",
"updateReview": "These draft changes will update existing catalog entries after publication.",
"saveDraft": "Save draft",
"saveHint": "Saving keeps the package as a draft. Save all changes before requesting a publication review.",
"draftSaved": "Draft saved. Publication is a separate step.",
"reviewPublication": "Review publication",
"publicationImpact": "Publication affects {pages} categories and {offers} offers.",
"conflicts": "Publication is blocked by conflicts. Resolve them before requesting another review.",
"readyToPublish": "The saved version passed the publication check. Publishing changes the live catalog.",
"publish": "Publish package",
"confirmPublish": "Publish this reviewed package to the live catalog now?",
"publishedSuccess": "Package published: {pages} categories and {offers} offers.",
"staleReview": "This package changed in another session. Reopen it to review the latest saved version.",
"unsaved": "Unsaved changes",
"discardChanges": "Discard unsaved package changes and continue?",
"working": "Working…",
"requestFailed": "The request failed. Try again.",
"invalidFields": "Check required names, layouts and whole-number fields before saving.",
"category": "Category",
"caption": "Category caption",
"iconImage": "Category icon ID",
"minRank": "Minimum rank",
"orderNum": "Display order",
"pageLayout": "Page layout",
"enabled": "Enabled",
"visible": "Visible",
"clubOnly": "Club members only",
"vipOnly": "VIP members only",
"offers": "Offers ({count})",
"offer": "Offer",
"catalogName": "Offer name",
"costCredits": "Credits",
"costPoints": "Points",
"pointsType": "Points currency ID",
"amount": "Amount",
"searchOffers": "Search offers",
"currentCategoryOffers": "Only the selected category",
"selectMatching": "Select all matching offers ({count})",
"clearSelection": "Clear selection",
"selectedOffers": "Selected offers: {count}",
"select": "Select",
"selectOffer": "Select offer #{id}",
"noOffers": "No offers match these filters.",
"bulkPrices": "Bulk draft prices ({count} selected)",
"bulkHint": "Preview the selected price changes, then accept them into this draft. The live catalog remains unchanged until publication.",
"operation": "Operation",
"set": "Set value",
"add": "Add or subtract",
"percent": "Adjust by percent",
"value": "Value",
"reviewBulk": "Preview prices",
"invalidBulk": "Choose valid numbers and at least one offer. Resulting prices must be nonnegative and within the supported range.",
"acceptBulk": "Accept prices into draft",
"previous": "Previous",
"next": "Next"
}
},
"shop": {
@@ -3084,6 +3174,69 @@
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
},
"catalogPackages": {
"backend": {
"sourceChanged": "Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
"saveConflict": "Package changed. Reload it before saving",
"publishConflict": "Package changed. Refresh the publication preview",
"catalogChanged": "Catalog changed. Refresh the publication preview",
"missing": "Catalog package not found",
"pageLimit": "A package supports at most 200 categories including descendants",
"offerLimit": "A package supports at most 500 offers",
"payloadLimit": "Package data exceeds 8 MB safety limit",
"membership": "Package membership cannot change",
"hierarchy": "Package hierarchy cannot change",
"references": "Package furniture references cannot change",
"furnitureMissing": "Package furniture reference no longer exists",
"auditWarning": "Package published, but audit logging failed.",
"hotelWarning": "Package published, but the hotel update was not delivered.",
"exportWarning": "Package published, but export scheduling failed.",
"refreshWarning": "Package published. Refresh the page to see the latest catalog."
}
},
"catalogPackagePreview": {
"title": "Catalog preview",
"hint": "Browse the actual package pages and offers. This is a catalog preview, not a full game-client rendering. No purchases are made.",
"search": "Search categories or offers",
"playerMode": "Player access",
"rank": "Rank",
"club": "Club",
"vip": "VIP",
"externalParents": "This selection starts inside a larger catalog. Access restrictions on ancestors outside this snapshot cannot be evaluated.",
"invalidHierarchy": "{count} pages have a cyclic hierarchy and cannot be navigated.",
"snapshotLimit": "Preview limited to 200 pages and 500 offers.",
"restricted": "{count} pages are hidden by the selected player access settings.",
"categories": "Catalog categories",
"noPages": "This snapshot contains no categories.",
"inaccessible": "No categories are accessible with these settings. Adjust player access or turn it off to inspect the draft.",
"noMatches": "No matches for this search.",
"offerCount": "{count} offers",
"layout": "Layout: {layout}",
"missingLayout": "Not specified",
"unsupportedLayout": "This layout has no dedicated preview. The grid shows the actual offers; custom layout widgets and imagery are not reproduced.",
"loadingIcons": "Loading furniture icons…",
"assetsUnavailable": "Furniture details are temporarily unavailable. Offer data remains visible.",
"assetLimit": "Icons and furniture names are limited to the first 200 item IDs on this view.",
"emptyPage": "This category has no direct offers. Browse its subcategories.",
"missingIcon": "Icon unavailable",
"amount": "Quantity: {count}",
"bundle": "{count} furniture types",
"changed": "Changed from the saved baseline",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {total}",
"offerId": "Offer #{id}",
"unknownFurniture": "Furniture name unavailable",
"selectOffer": "Select an offer to inspect its price and furniture references.",
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"pointsType": "Points (type {type}):",
"free": "Free",
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
"restrictedOffers": "{count} offers in this category require Club membership.",
"clubOffer": "Club offer"
}
},
"error": {