diff --git a/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx b/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx
index 4093bce4..9d09306c 100644
--- a/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx
+++ b/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx
@@ -195,13 +195,19 @@ export function CatalogItemsTable({
// ── Search / Filter ─────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState(initialSearch);
- const [offerFilter, setOfferFilter] = useState("all");
+ const [filters, setFilters] = useState>({
+ offerFilter: "all",
+ clubOnlyFilter: "all",
+ haveOfferFilter: "all",
+ });
+
const { items: filteredItems, counts: filterCounts } = useMemo(
- () => discoverOffers(items, searchQuery, offerFilter),
- [items, searchQuery, offerFilter],
+ () => discoverOffers(items, searchQuery, filters),
+ [items, searchQuery, filters],
);
const hasDiscoveryFilter =
- Boolean(searchQuery.trim()) || offerFilter !== "all";
+ Boolean(searchQuery.trim()) ||
+ Object.values(filters).some((f) => f !== "all");
// ── Move items dialog ───────────────────────────────────────────
const [moveOpen, setMoveOpen] = useState(false);
@@ -225,12 +231,13 @@ export function CatalogItemsTable({
);
const orderedFilteredItems = useMemo(() => {
- if (searchQuery.trim()) return filteredItems;
+ if (searchQuery.trim() || Object.values(filters).some((f) => f !== "all"))
+ return filteredItems;
const orderMap = new Map(itemOrder.map((id, idx) => [id, idx]));
- return [...filteredItems].sort(
- (a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0),
- );
- }, [filteredItems, itemOrder, searchQuery]);
+ return filteredItems
+ .slice()
+ .sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
+ }, [filteredItems, itemOrder, searchQuery, filters]);
// ── Selection ────────────────────────────────────────────────────
const [selected, setSelected] = useState>(new Set());
@@ -265,13 +272,17 @@ export function CatalogItemsTable({
setSearchQuery(query);
setSelected(new Set());
}
- function updateOfferFilter(value: string) {
- setOfferFilter(value as OfferFilter);
+ function updateFilter(key: string, value: string) {
+ setFilters((prev) => ({ ...prev, [key]: value }));
setSelected(new Set());
}
function resetDiscovery() {
setSearchQuery("");
- setOfferFilter("all");
+ setFilters({
+ offerFilter: "all",
+ clubOnlyFilter: "all",
+ haveOfferFilter: "all",
+ });
setSelected(new Set());
}
@@ -1187,18 +1198,21 @@ export function CatalogItemsTable({
onClear={() => updateSearch("")}
/>
-
) : viewMode === "grid" ? (
- {t("audit.exportHint")}
+
+
{t("audit.exportHint")}
+
+
);
diff --git a/src/app/api/admin/logs/audit/export/route.test.ts b/src/app/api/admin/logs/audit/export/route.test.ts
index d38b312d..3ca2801a 100644
--- a/src/app/api/admin/logs/audit/export/route.test.ts
+++ b/src/app/api/admin/logs/audit/export/route.test.ts
@@ -31,7 +31,7 @@ vi.mock("@/lib/services/audit", () => ({
import { getAuditLogs } from "@/lib/services/audit";
import { GET } from "./route";
-describe("audit CSV", () => {
+describe("audit export", () => {
beforeEach(() => {
vi.mocked(getAuditLogs).mockClear();
});
@@ -54,6 +54,19 @@ describe("audit CSV", () => {
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
});
+ it("supports JSON export format", async () => {
+ const response = await GET(
+ new Request(
+ "https://example.test/api/admin/logs/audit/export?format=json",
+ ) as never,
+ {} as never,
+ );
+ expect(response.headers.get("Content-Type")).toContain("application/json");
+ const json = await response.json();
+ expect(Array.isArray(json)).toBe(true);
+ expect(json[0].id).toBe(1);
+ expect(json[0].username).toBe('=HYPERLINK("bad")');
+ });
it("escapes CSV formula cells and redacts historical secrets", async () => {
const response = await GET(
new Request("https://example.test/api/admin/logs/audit/export") as never,
diff --git a/src/app/api/admin/logs/audit/export/route.ts b/src/app/api/admin/logs/audit/export/route.ts
index f38ec28a..48c031ce 100644
--- a/src/app/api/admin/logs/audit/export/route.ts
+++ b/src/app/api/admin/logs/audit/export/route.ts
@@ -23,6 +23,7 @@ export const GET = withAdmin(
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
});
+ const format = params.get("format") || "csv";
const rows: unknown[][] = [
[
"id",
@@ -35,8 +36,17 @@ export const GET = withAdmin(
"changes",
],
];
+ const jsonItems = [];
for (const row of result.rows) {
const details = readAuditChanges(row.diff, row.before, row.after);
+ const changesFormatted = details.invalid
+ ? "[Unavailable legacy details]"
+ : details.changes
+ .map(
+ (change) =>
+ `${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
+ )
+ .join("\n");
rows.push([
row.id,
row.userId,
@@ -45,15 +55,27 @@ export const GET = withAdmin(
row.target,
row.targetId,
row.createdAt,
- details.invalid
- ? "[Unavailable legacy details]"
- : details.changes
- .map(
- (change) =>
- `${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
- )
- .join("\n"),
+ changesFormatted,
]);
+ jsonItems.push({
+ id: row.id,
+ userId: row.userId,
+ username: row.username,
+ action: row.action,
+ target: row.target,
+ targetId: row.targetId,
+ createdAt: row.createdAt,
+ changes: details.changes,
+ });
+ }
+ if (format === "json") {
+ return new Response(JSON.stringify(jsonItems, null, 2), {
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Content-Disposition": 'attachment; filename="audit-page.json"',
+ "Cache-Control": "no-store",
+ },
+ });
}
return new Response(
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
diff --git a/src/features/catalog/domain/offer-discovery.test.ts b/src/features/catalog/domain/offer-discovery.test.ts
index 099ad881..7f599c24 100644
--- a/src/features/catalog/domain/offer-discovery.test.ts
+++ b/src/features/catalog/domain/offer-discovery.test.ts
@@ -11,6 +11,8 @@ const offers = [
costCredits: 0,
costPoints: 0,
limitedStack: 0,
+ clubOnly: "0",
+ haveOffer: "0",
},
{
id: 8,
@@ -21,6 +23,8 @@ const offers = [
costCredits: 0,
costPoints: 5,
limitedStack: 0,
+ clubOnly: "0",
+ haveOffer: "1",
},
{
id: 9,
@@ -31,6 +35,8 @@ const offers = [
costCredits: 15,
costPoints: 0,
limitedStack: 100,
+ clubOnly: "1",
+ haveOffer: "0",
},
{
id: 10,
@@ -41,6 +47,8 @@ const offers = [
costCredits: 0,
costPoints: 0,
limitedStack: 0,
+ clubOnly: "0",
+ haveOffer: "0",
},
];
@@ -48,32 +56,44 @@ describe("offer discovery", () => {
it("matches #offer IDs exactly without matching longer IDs or furniture references", () => {
const rows = [offers[0], { ...offers[1], id: 17, itemIds: "7" }];
expect(
- discoverOffers(rows, " #7 ", "all").items.map((item) => item.id),
+ discoverOffers(rows, " #7 ", { offerFilter: "all" }).items.map(
+ (item) => item.id,
+ ),
).toEqual([7]);
});
it("requires both prices to be zero for free offers and includes points-only paid offers", () => {
expect(
- discoverOffers(offers, "", "free").items.map((item) => item.id),
+ discoverOffers(offers, "", { offerFilter: "free" }).items.map(
+ (item) => item.id,
+ ),
).toEqual([7, 10]);
expect(
- discoverOffers(offers, "", "paid").items.map((item) => item.id),
+ discoverOffers(offers, "", { offerFilter: "paid" }).items.map(
+ (item) => item.id,
+ ),
).toEqual([8, 9]);
});
it("recognizes semicolon bundles without requiring a single base item", () => {
expect(
- discoverOffers(offers, "", "bundles").items.map((item) => item.id),
+ discoverOffers(offers, "", { offerFilter: "bundles" }).items.map(
+ (item) => item.id,
+ ),
).toEqual([8, 10]);
expect(
- discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", "bundles").items,
+ discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", {
+ offerFilter: "bundles",
+ }).items,
).toEqual([]);
});
it("identifies limited editions by stock even without sold quantities", () => {
expect(
- discoverOffers(offers, "", "limited").items.map((item) => item.id),
+ discoverOffers(offers, "", { offerFilter: "limited" }).items.map(
+ (item) => item.id,
+ ),
).toEqual([9]);
});
it("combines trimmed case-insensitive text with a facet while counting other available facets", () => {
- const result = discoverOffers(offers, " CHAIR ", "paid");
+ const result = discoverOffers(offers, " CHAIR ", { offerFilter: "paid" });
expect(result.items.map((item) => item.id)).toEqual([8]);
expect(result.counts).toEqual({
all: 2,
@@ -81,28 +101,52 @@ describe("offer discovery", () => {
paid: 1,
bundles: 1,
limited: 0,
+ club_only_yes: 0,
+ club_only_no: 2,
+ have_offer_yes: 1,
+ have_offer_no: 3,
});
});
it("searches public names, classnames, offer IDs and furniture references", () => {
expect(
- discoverOffers(offers, "Wooden", "all").items.map((item) => item.id),
+ discoverOffers(offers, "Wooden", { offerFilter: "all" }).items.map(
+ (item) => item.id,
+ ),
).toEqual([7, 9]);
expect(
- discoverOffers(offers, "table_oak", "all").items.map((item) => item.id),
+ discoverOffers(offers, "table_oak", { offerFilter: "all" }).items.map(
+ (item) => item.id,
+ ),
).toEqual([9]);
expect(
- discoverOffers(offers, "7", "all").items.map((item) => item.id),
+ discoverOffers(offers, "7", { offerFilter: "all" }).items.map(
+ (item) => item.id,
+ ),
).toEqual([7]);
expect(
- discoverOffers(offers, "11", "all").items.map((item) => item.id),
+ discoverOffers(offers, "11", { offerFilter: "all" }).items.map(
+ (item) => item.id,
+ ),
).toEqual([8]);
});
it("keeps source ordering and returns empty counts when the query has no matches", () => {
const copy = structuredClone(offers);
- expect(discoverOffers(offers, " ", "all").items).toEqual(offers);
- expect(discoverOffers(offers, "absent", "all")).toEqual({
+ expect(discoverOffers(offers, " ", { offerFilter: "all" }).items).toEqual(
+ offers,
+ );
+ expect(discoverOffers(offers, "absent", { offerFilter: "all" })).toEqual({
items: [],
- counts: { all: 0, free: 0, paid: 0, bundles: 0, limited: 0 },
+ counts: {
+ all: 0,
+ free: 0,
+ paid: 0,
+ bundles: 0,
+ limited: 0,
+ club_only_yes: 0,
+ club_only_no: 0,
+ have_offer_yes: 0,
+ have_offer_no: 0,
+ },
});
expect(offers).toEqual(copy);
});
diff --git a/src/features/catalog/domain/offer-discovery.ts b/src/features/catalog/domain/offer-discovery.ts
index 44e04c0a..cf2b12ea 100644
--- a/src/features/catalog/domain/offer-discovery.ts
+++ b/src/features/catalog/domain/offer-discovery.ts
@@ -6,6 +6,10 @@ export const OFFER_FILTERS = [
"paid",
"bundles",
"limited",
+ "club_only_yes",
+ "club_only_no",
+ "have_offer_yes",
+ "have_offer_no",
] as const;
export type OfferFilter = (typeof OFFER_FILTERS)[number];
type DiscoverableOffer = Pick<
@@ -18,7 +22,10 @@ type DiscoverableOffer = Pick<
| "costCredits"
| "costPoints"
| "limitedStack"
->;
+> & {
+ clubOnly?: string;
+ haveOffer?: string;
+};
function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
switch (filter) {
@@ -35,6 +42,14 @@ function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
);
case "limited":
return item.limitedStack > 0;
+ case "club_only_yes":
+ return item.clubOnly === "1";
+ case "club_only_no":
+ return item.clubOnly === "0";
+ case "have_offer_yes":
+ return item.haveOffer === "1";
+ case "have_offer_no":
+ return item.haveOffer === "0";
}
}
@@ -42,37 +57,73 @@ function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
export function discoverOffers(
items: readonly T[],
query: string,
- filter: OfferFilter,
+ filters: Record,
) {
const normalized = query.trim().toLowerCase();
const exactId = /^#\d+$/.test(normalized) ? normalized.slice(1) : null;
- const matches =
- exactId !== null
- ? items.filter((item) => String(item.id) === exactId)
- : normalized
- ? items.filter((item) =>
- [
- item.catalogName,
- item.baseItemName,
- item.baseName,
- item.itemIds,
- String(item.id),
- ].some((value) => value.toLowerCase().includes(normalized)),
- )
- : [...items];
+
+ let matches = items;
+
+ // Apply text search
+ if (exactId !== null) {
+ matches = matches.filter((item) => String(item.id) === exactId);
+ } else if (normalized) {
+ matches = matches.filter((item) =>
+ [
+ item.catalogName,
+ item.baseItemName,
+ item.baseName,
+ item.itemIds,
+ String(item.id),
+ ].some((value) => value.toLowerCase().includes(normalized)),
+ );
+ }
+
+ // Apply multiple filters
+ for (const filterKey in filters) {
+ const filterValue = filters[filterKey];
+ if (filterValue && filterValue !== "all") {
+ matches = matches.filter((item) =>
+ matchesFilter(item, filterValue as OfferFilter),
+ );
+ }
+ }
+
const counts = {
all: matches.length,
free: 0,
paid: 0,
bundles: 0,
limited: 0,
+ club_only_yes: 0,
+ club_only_no: 0,
+ have_offer_yes: 0,
+ have_offer_no: 0,
};
- for (const item of matches)
+
+ // Calculate facet counts based on *all* items matching the text query, before specific filters are applied
+ const initialMatchesForFacets = items.filter((item) => {
+ if (exactId !== null) return String(item.id) === exactId;
+ if (normalized)
+ return [
+ item.catalogName,
+ item.baseItemName,
+ item.baseName,
+ item.itemIds,
+ String(item.id),
+ ].some((value) => value.toLowerCase().includes(normalized));
+ return true; // If no text query, all items are initial matches
+ });
+
+ for (const item of initialMatchesForFacets) {
for (const facet of OFFER_FILTERS) {
if (facet !== "all" && matchesFilter(item, facet)) counts[facet] += 1;
}
+ }
+
+ // The `items` property in the return object should now be the fully filtered `matches`.
return {
- items: matches.filter((item) => matchesFilter(item, filter)),
+ items: matches,
counts,
};
}
diff --git a/src/i18n/commandocentrum-messages.test.ts b/src/i18n/commandocentrum-messages.test.ts
index 0fcc4c86..ba2e41b8 100644
--- a/src/i18n/commandocentrum-messages.test.ts
+++ b/src/i18n/commandocentrum-messages.test.ts
@@ -48,6 +48,5 @@ describe("command center translations", () => {
expect(t("totalAccounts", { count: 42 })).toBe("42 total accounts");
expect(t("groups.players")).toBe("Players");
expect(t("checkedAt", { time: "12:34" })).toBe("Checked at 12:34");
-
});
});
diff --git a/src/messages/en.json b/src/messages/en.json
index 7d9869db..35289d61 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -2331,12 +2331,18 @@
"discovery": {
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
"filterLabel": "Filter offers",
+ "clubOnlyFilterLabel": "HC Only Filter",
+ "haveOfferFilterLabel": "Has Offer Filter",
"filters": {
"all": "All offers",
"free": "Free",
"paid": "Paid",
"bundles": "Bundles",
- "limited": "Limited editions"
+ "limited": "Limited editions",
+ "club_only_yes": "HC Only",
+ "club_only_no": "Not HC Only",
+ "have_offer_yes": "Has Offer",
+ "have_offer_no": "No Offer"
},
"shown": "({count} shown)",
"selected": "{count} selected",