feat(catalog): add saved packages, preview and bulk price editing
This commit is contained in:
1 parent
867113d5d4
commit
047cd9f3dd
48 files changed
+7712
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user