feat(catalog): add saved packages, preview and bulk price editing
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 1m23s
CI / publish-container (push) Failing after 27s

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

@@ -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>
);
}