@mohasinac/appkit 4.51.0 → 4.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +3 -3
- package/dist/features/categories/components/CategoriesIndexListing.js +25 -12
- package/dist/features/categories/components/CategoryGrid.js +2 -1
- package/dist/features/categories/constants/category-tabs.d.ts +64 -0
- package/dist/features/categories/constants/category-tabs.js +88 -0
- package/dist/features/categories/hooks/useCategories.d.ts +14 -0
- package/dist/features/categories/hooks/useCategories.js +2 -0
- package/dist/features/categories/types/index.d.ts +12 -0
- package/dist/seed/categories-seed-data.js +29 -1
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.52.0",
|
|
4
|
+
"head": "5e6e4777891284de76aa0d646e556f8aee0ff76e",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-14T12:
|
|
7
|
+
"builtAt": "2026-10-14T12:39:32.737Z"
|
|
8
8
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useCallback, useMemo } from "react";
|
|
4
4
|
import { useCategoriesFiltered } from "../hooks/useCategories.js";
|
|
5
|
+
import { CATEGORY_TABS, DEFAULT_CATEGORY_TAB, matchesCategoryTab, } from "../constants/category-tabs.js";
|
|
5
6
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
6
7
|
import { Button, Div, Grid, ListingFilterDrawer, ListingToolbar, Pagination, Row, Stack, Text, StickyToolbar } from "../../../ui/index.js";
|
|
7
8
|
import { CategoryCard } from "./CategoryGrid.js";
|
|
@@ -20,11 +21,16 @@ const PAGE_SIZE = 24;
|
|
|
20
21
|
function renderCategoryGrid(props) {
|
|
21
22
|
const { isLoading, categories, view, activeSearch, activeTab, brandsOnly } = props;
|
|
22
23
|
const isBrandView = activeTab === "brands" || brandsOnly;
|
|
24
|
+
/* The empty state names the tab the visitor is actually on. It said
|
|
25
|
+
* "categories" on all five, so an empty Parts tab reported the wrong thing. */
|
|
26
|
+
const noun = brandsOnly
|
|
27
|
+
? "brands"
|
|
28
|
+
: (CATEGORY_TABS.find((t) => t.key === activeTab)?.label ?? "categories").toLowerCase();
|
|
23
29
|
if (isLoading) {
|
|
24
30
|
return (_jsx(Grid, { cols: "categoryCards", gap: "md", children: Array.from({ length: 10 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-[4/3]", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}.5`, gap: "sm", children: [_jsx(Div, { className: "h-3.5 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" })] })] }, i))) }));
|
|
25
31
|
}
|
|
26
32
|
if (categories.length === 0) {
|
|
27
|
-
return (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: activeSearch ? `No ${
|
|
33
|
+
return (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: activeSearch ? `No ${noun} matching "${activeSearch}"` : `No ${noun} found` }));
|
|
28
34
|
}
|
|
29
35
|
if (view === "list") {
|
|
30
36
|
return (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: categories.map((category) => _jsx(CategoryCard, { category: category, href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category.slug)) }, category.id)) }));
|
|
@@ -131,12 +137,21 @@ export function CategoriesIndexListing({ brandsOnly = false }) {
|
|
|
131
137
|
const commitSearch = useCallback(() => {
|
|
132
138
|
table.set(TABLE_KEYS.QUERY, searchInput.trim());
|
|
133
139
|
}, [searchInput, table]);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
+
/*
|
|
141
|
+
* Tab state. Five tabs, no "All" — see `category-tabs.ts`: "All" was the
|
|
142
|
+
* default and it was what rendered 1,185 undifferentiated rows.
|
|
143
|
+
*/
|
|
144
|
+
const rawTab = brandsOnly ? "brands" : table.get(TABLE_KEYS.TAB) || DEFAULT_CATEGORY_TAB;
|
|
145
|
+
const activeTab = (CATEGORY_TABS.some((t) => t.key === rawTab)
|
|
146
|
+
? rawTab
|
|
147
|
+
: DEFAULT_CATEGORY_TAB);
|
|
148
|
+
/*
|
|
149
|
+
* 🛑 The fetch stays unfiltered (`?flat=true`) and the TABS partition that one
|
|
150
|
+
* cached response — a per-tab query would turn one request into five for the
|
|
151
|
+
* same data. `isBrand` is still sent for the brands tab because the API has a
|
|
152
|
+
* dedicated clause for it; everything else is partitioned below.
|
|
153
|
+
*/
|
|
154
|
+
const isBrandParam = activeTab === "brands" ? true : undefined;
|
|
140
155
|
const switchTab = (key) => {
|
|
141
156
|
table.set(TABLE_KEYS.TAB, key);
|
|
142
157
|
};
|
|
@@ -144,6 +159,8 @@ export function CategoriesIndexListing({ brandsOnly = false }) {
|
|
|
144
159
|
q: table.get(TABLE_KEYS.QUERY) || undefined,
|
|
145
160
|
isFeatured: table.get(TABLE_KEYS.IS_FEATURED) === "true" || undefined,
|
|
146
161
|
isBrand: isBrandParam,
|
|
162
|
+
// The tab partition — applied before pagination inside the hook.
|
|
163
|
+
predicate: (c) => matchesCategoryTab(c, activeTab),
|
|
147
164
|
rootOnly: table.get("rootOnly") === "true" || undefined,
|
|
148
165
|
tier: getNumericParam(table, "tier"),
|
|
149
166
|
minProductCount: getNumericParam(table, "minItemCount"),
|
|
@@ -153,11 +170,7 @@ export function CategoriesIndexListing({ brandsOnly = false }) {
|
|
|
153
170
|
pageSize: PAGE_SIZE,
|
|
154
171
|
});
|
|
155
172
|
const activeSearch = table.get(TABLE_KEYS.QUERY) || "";
|
|
156
|
-
const TABS =
|
|
157
|
-
{ key: "all", label: "All" },
|
|
158
|
-
{ key: "categories", label: "Categories" },
|
|
159
|
-
{ key: "brands", label: "Brands" },
|
|
160
|
-
];
|
|
173
|
+
const TABS = CATEGORY_TABS;
|
|
161
174
|
return (_jsxs(Div, { className: "min-h-screen", children: [!brandsOnly && (_jsx(Row, { gap: "xs", border: "default", className: "border-b mb-2", children: TABS.map((tab) => (_jsx(Button, { variant: "ghost", type: "button", onClick: () => switchTab(tab.key), paddingX: "md", paddingY: "y-xs-tall", textSize: "sm", weight: "medium", className: [
|
|
162
175
|
"transition-colors border-b-2 -mb-px rounded-none",
|
|
163
176
|
activeTab === tab.key
|
|
@@ -4,6 +4,7 @@ import { ArrowRight } from "lucide-react";
|
|
|
4
4
|
import { Div, Grid, Row, Span, Stack, Text } from "../../../ui/index.js";
|
|
5
5
|
import { DynamicBgDiv } from "../../../ui/components/DynamicBgDiv.js";
|
|
6
6
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
7
|
+
import { ImageMosaic, MOSAIC_MIN } from "../../../ui/components/ImageMosaic.js";
|
|
7
8
|
const __P = {
|
|
8
9
|
p3: "p-[var(--appkit-space-3)]",
|
|
9
10
|
};
|
|
@@ -17,7 +18,7 @@ export function CategoryCard({ category, href, onClick, className = "", }) {
|
|
|
17
18
|
// own-node-only productCount so a parent category's card shows the same
|
|
18
19
|
// "N products" a visitor actually sees once they open the page.
|
|
19
20
|
const productCount = category.metrics?.totalProductCount ?? category.metrics?.productCount ?? category.productCount ?? 0;
|
|
20
|
-
const inner = (_jsxs(Stack, { className: "h-full", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-[4/3] w-full ${__O.hidden} flex-shrink-0`, children: [category.display?.coverImage ? (_jsx(MediaImage, { src: category.display.coverImage, alt: category.name, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : category.display?.color ? (_jsx(DynamicBgDiv, { color: category.display.color, className: "h-full w-full opacity-80" })) : null, category.display?.icon && (_jsx(Row, { textSize: "4xl", className: "absolute inset-0", align: "center", justify: "center", children: category.display.icon })), category.isFeatured && (_jsx(Span, { size: "xs", className: CLS_FEATURED_DOT, children: "\u2605" }))] }), _jsxs(Stack, { className: `flex-1 ${__P.p3}.5`, children: [_jsx(Text, { color: "inverse", className: `leading-snug text-[var(--appkit-color-text)] dark:`, truncate: 2, size: "sm", weight: "semibold", children: category.name }), category.description && (_jsx(Text, { className: `mt-1 flex-1`, color: "muted", truncate: 2, size: "xs", children: category.description })), _jsxs(Row, { className: "mt-2", align: "center", justify: "between", gap: "sm", children: [_jsxs(Text, { size: "xs", color: "faint", children: [productCount.toLocaleString(), " ", productCount === 1 ? "item" : "items"] }), _jsxs(Span, { layout: "inline-flex", gap: "xs", size: "xs", weight: "medium", border: "default", className: "group-hover:bg-primary group-hover:border-primary group-hover:text-white transition-colors", rounded: "md", padding: "pill-sm-tall", color: "muted", children: ["Browse ", _jsx(ArrowRight, { className: "h-3 w-3" })] })] })] })] }));
|
|
21
|
+
const inner = (_jsxs(Stack, { className: "h-full", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-[4/3] w-full ${__O.hidden} flex-shrink-0`, children: [(category.display?.mosaicImages?.length ?? 0) >= MOSAIC_MIN ? (_jsx(ImageMosaic, { images: category.display.mosaicImages, alt: category.name, className: "h-full w-full" })) : category.display?.coverImage ? (_jsx(MediaImage, { src: category.display.coverImage, alt: category.name, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : category.display?.color ? (_jsx(DynamicBgDiv, { color: category.display.color, className: "h-full w-full opacity-80" })) : null, category.display?.icon && (_jsx(Row, { textSize: "4xl", className: "absolute inset-0", align: "center", justify: "center", children: category.display.icon })), category.isFeatured && (_jsx(Span, { size: "xs", className: CLS_FEATURED_DOT, children: "\u2605" }))] }), _jsxs(Stack, { className: `flex-1 ${__P.p3}.5`, children: [_jsx(Text, { color: "inverse", className: `leading-snug text-[var(--appkit-color-text)] dark:`, truncate: 2, size: "sm", weight: "semibold", children: category.name }), category.description && (_jsx(Text, { className: `mt-1 flex-1`, color: "muted", truncate: 2, size: "xs", children: category.description })), _jsxs(Row, { className: "mt-2", align: "center", justify: "between", gap: "sm", children: [_jsxs(Text, { size: "xs", color: "faint", children: [productCount.toLocaleString(), " ", productCount === 1 ? "item" : "items"] }), _jsxs(Span, { layout: "inline-flex", gap: "xs", size: "xs", weight: "medium", border: "default", className: "group-hover:bg-primary group-hover:border-primary group-hover:text-white transition-colors", rounded: "md", padding: "pill-sm-tall", color: "muted", children: ["Browse ", _jsx(ArrowRight, { className: "h-3 w-3" })] })] })] })] }));
|
|
21
22
|
const cardClass = `group relative flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-[var(--appkit-color-surface)] border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] shadow-sm transition hover:shadow-md h-full ${className}`;
|
|
22
23
|
if (href) {
|
|
23
24
|
return (_jsx(Link, { href: href, className: cardClass, children: inner }));
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the categories index splits into tabs — ONE definition of each predicate.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 The index shows ROOTS, not everything
|
|
5
|
+
*
|
|
6
|
+
* `/categories` used to render the whole flat forest: 1,185 rows, of which 752
|
|
7
|
+
* are part leaves and 220 are model leaves. A visitor met a wall of names with
|
|
8
|
+
* no sense of where they were. The Categories tab now shows the SIX roots and
|
|
9
|
+
* you walk down — parent to child to leaf — which is how every catalogue of
|
|
10
|
+
* this shape is browsed, and what the category detail page already supports
|
|
11
|
+
* (it fetches every descendant and renders them tier-grouped).
|
|
12
|
+
*
|
|
13
|
+
* ## 🛑 Parts cannot be matched on `categoryType`
|
|
14
|
+
*
|
|
15
|
+
* Brands, franchises and bundles each carry a discriminator. Parts deliberately
|
|
16
|
+
* DO NOT — `gen-category-parts.mjs` omits `categoryType` because 92 files read
|
|
17
|
+
* it and a new value opts a row out of every path that works. So a part is
|
|
18
|
+
* identified by its ANCESTRY: a leaf whose chain passes through one of the four
|
|
19
|
+
* `*-parts` nodes. That is a fact about where it lives, not a flag that can
|
|
20
|
+
* drift from it.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the tabs partition ONE payload
|
|
23
|
+
*
|
|
24
|
+
* The listing already fetches `?flat=true` once. Deriving all five tabs from
|
|
25
|
+
* that single cached response costs no extra request — a per-tab query would
|
|
26
|
+
* turn one fetch into five for the same data.
|
|
27
|
+
*
|
|
28
|
+
* @tag domain:categories
|
|
29
|
+
* @tag layer:constants
|
|
30
|
+
* @tag pattern:shared-literal
|
|
31
|
+
* @tag sideEffects:none
|
|
32
|
+
*/
|
|
33
|
+
/** The tier-2 nodes every part leaf hangs beneath. */
|
|
34
|
+
export declare const PART_PARENT_IDS: readonly string[];
|
|
35
|
+
export type CategoryTabKey = "categories" | "brands" | "franchises" | "parts" | "bundles";
|
|
36
|
+
export interface CategoryTabDef {
|
|
37
|
+
key: CategoryTabKey;
|
|
38
|
+
label: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 🛑 No "All" tab. It was the default and it was the problem — it is what
|
|
42
|
+
* rendered 1,185 undifferentiated rows. Every tab here answers a question
|
|
43
|
+
* ("which parts do you have?"); "all" answers none of them.
|
|
44
|
+
*/
|
|
45
|
+
export declare const CATEGORY_TABS: readonly CategoryTabDef[];
|
|
46
|
+
export declare const DEFAULT_CATEGORY_TAB: CategoryTabKey;
|
|
47
|
+
/** The minimum shape these predicates read. */
|
|
48
|
+
interface TabbableCategory {
|
|
49
|
+
categoryType?: string | null;
|
|
50
|
+
tier?: number | null;
|
|
51
|
+
parentIds?: readonly string[] | null;
|
|
52
|
+
isLeaf?: boolean | null;
|
|
53
|
+
}
|
|
54
|
+
/** A leaf whose ancestry passes through a `*-parts` node. */
|
|
55
|
+
export declare function isPartCategory(c: TabbableCategory): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Does this row belong on the given tab?
|
|
58
|
+
*
|
|
59
|
+
* 🛑 The Categories tab is ROOTS ONLY (`tier === 0`). Brands, franchises and
|
|
60
|
+
* bundles are tier-0 too but carry a discriminator, so the type check has to
|
|
61
|
+
* come first or they would appear under Categories as well.
|
|
62
|
+
*/
|
|
63
|
+
export declare function matchesCategoryTab(c: TabbableCategory, tab: CategoryTabKey): boolean;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the categories index splits into tabs — ONE definition of each predicate.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 The index shows ROOTS, not everything
|
|
5
|
+
*
|
|
6
|
+
* `/categories` used to render the whole flat forest: 1,185 rows, of which 752
|
|
7
|
+
* are part leaves and 220 are model leaves. A visitor met a wall of names with
|
|
8
|
+
* no sense of where they were. The Categories tab now shows the SIX roots and
|
|
9
|
+
* you walk down — parent to child to leaf — which is how every catalogue of
|
|
10
|
+
* this shape is browsed, and what the category detail page already supports
|
|
11
|
+
* (it fetches every descendant and renders them tier-grouped).
|
|
12
|
+
*
|
|
13
|
+
* ## 🛑 Parts cannot be matched on `categoryType`
|
|
14
|
+
*
|
|
15
|
+
* Brands, franchises and bundles each carry a discriminator. Parts deliberately
|
|
16
|
+
* DO NOT — `gen-category-parts.mjs` omits `categoryType` because 92 files read
|
|
17
|
+
* it and a new value opts a row out of every path that works. So a part is
|
|
18
|
+
* identified by its ANCESTRY: a leaf whose chain passes through one of the four
|
|
19
|
+
* `*-parts` nodes. That is a fact about where it lives, not a flag that can
|
|
20
|
+
* drift from it.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the tabs partition ONE payload
|
|
23
|
+
*
|
|
24
|
+
* The listing already fetches `?flat=true` once. Deriving all five tabs from
|
|
25
|
+
* that single cached response costs no extra request — a per-tab query would
|
|
26
|
+
* turn one fetch into five for the same data.
|
|
27
|
+
*
|
|
28
|
+
* @tag domain:categories
|
|
29
|
+
* @tag layer:constants
|
|
30
|
+
* @tag pattern:shared-literal
|
|
31
|
+
* @tag sideEffects:none
|
|
32
|
+
*/
|
|
33
|
+
/** The tier-2 nodes every part leaf hangs beneath. */
|
|
34
|
+
export const PART_PARENT_IDS = [
|
|
35
|
+
"category-original-parts",
|
|
36
|
+
"category-metal-parts",
|
|
37
|
+
"category-burst-parts",
|
|
38
|
+
"category-x-parts",
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* 🛑 No "All" tab. It was the default and it was the problem — it is what
|
|
42
|
+
* rendered 1,185 undifferentiated rows. Every tab here answers a question
|
|
43
|
+
* ("which parts do you have?"); "all" answers none of them.
|
|
44
|
+
*/
|
|
45
|
+
export const CATEGORY_TABS = [
|
|
46
|
+
{ key: "categories", label: "Categories" },
|
|
47
|
+
{ key: "brands", label: "Brands" },
|
|
48
|
+
{ key: "franchises", label: "Franchises" },
|
|
49
|
+
{ key: "parts", label: "Parts" },
|
|
50
|
+
{ key: "bundles", label: "Bundles" },
|
|
51
|
+
];
|
|
52
|
+
export const DEFAULT_CATEGORY_TAB = "categories";
|
|
53
|
+
const typeOf = (c) => String(c.categoryType ?? "");
|
|
54
|
+
/** A leaf whose ancestry passes through a `*-parts` node. */
|
|
55
|
+
export function isPartCategory(c) {
|
|
56
|
+
if (typeOf(c))
|
|
57
|
+
return false; // brand / bundle / franchise / sublisting
|
|
58
|
+
return (c.parentIds ?? []).some((p) => PART_PARENT_IDS.includes(String(p)));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Does this row belong on the given tab?
|
|
62
|
+
*
|
|
63
|
+
* 🛑 The Categories tab is ROOTS ONLY (`tier === 0`). Brands, franchises and
|
|
64
|
+
* bundles are tier-0 too but carry a discriminator, so the type check has to
|
|
65
|
+
* come first or they would appear under Categories as well.
|
|
66
|
+
*/
|
|
67
|
+
export function matchesCategoryTab(c, tab) {
|
|
68
|
+
const type = typeOf(c);
|
|
69
|
+
switch (tab) {
|
|
70
|
+
case "brands":
|
|
71
|
+
return type === "brand";
|
|
72
|
+
case "franchises":
|
|
73
|
+
return type === "franchise";
|
|
74
|
+
case "bundles":
|
|
75
|
+
return type === "bundle";
|
|
76
|
+
case "parts":
|
|
77
|
+
return isPartCategory(c);
|
|
78
|
+
case "categories":
|
|
79
|
+
/*
|
|
80
|
+
* A plain, top-level category. Parts are excluded by the type check
|
|
81
|
+
* being empty AND tier 0 — no part leaf is tier 0 — but a reader should
|
|
82
|
+
* not have to derive that, so the root test carries the meaning.
|
|
83
|
+
*/
|
|
84
|
+
return type === "" && Number(c.tier ?? -1) === 0;
|
|
85
|
+
default:
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -20,6 +20,20 @@ export interface CategoriesFilteredParams {
|
|
|
20
20
|
sort?: string;
|
|
21
21
|
page?: number;
|
|
22
22
|
pageSize?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Tab partition, applied BEFORE pagination.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 Before, not after — the hook paginates internally, so filtering the
|
|
27
|
+
* returned page would show a short grid and a page count for a different,
|
|
28
|
+
* larger set. It is a predicate rather than another flag because the Parts
|
|
29
|
+
* tab cannot be expressed as one: a part carries no `categoryType` and is
|
|
30
|
+
* identified by its ancestry (`category-tabs.ts`).
|
|
31
|
+
*
|
|
32
|
+
* Not part of the react-query key, deliberately — the FETCH is the same
|
|
33
|
+
* unfiltered `?flat=true` for every tab, so keying on it would mint five
|
|
34
|
+
* cache entries for one response.
|
|
35
|
+
*/
|
|
36
|
+
predicate?: (c: CategoryItem) => boolean;
|
|
23
37
|
}
|
|
24
38
|
/** API-driven for structured filters; text search + pagination done in hook (Firestore substring limitation). */
|
|
25
39
|
export declare function useCategoriesFiltered(params?: CategoriesFilteredParams): {
|
|
@@ -45,6 +45,8 @@ export function useCategoriesFiltered(params = {}) {
|
|
|
45
45
|
items = items.filter((c) => c.name.toLowerCase().includes(lq) || (c.description ?? "").toLowerCase().includes(lq));
|
|
46
46
|
if (params.isBrand === false)
|
|
47
47
|
items = items.filter((c) => !c.isBrand);
|
|
48
|
+
if (params.predicate)
|
|
49
|
+
items = items.filter(params.predicate);
|
|
48
50
|
if (minProductCount !== undefined)
|
|
49
51
|
items = items.filter((c) => (c.productCount ?? 0) >= minProductCount);
|
|
50
52
|
if (maxProductCount !== undefined)
|
|
@@ -10,6 +10,18 @@ export interface CategoryDisplay {
|
|
|
10
10
|
coverImage?: string;
|
|
11
11
|
color?: string;
|
|
12
12
|
showInMenu?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Member images for a collage, most important first — what a BUNDLE actually
|
|
15
|
+
* looks like, which is its contents rather than one tile.
|
|
16
|
+
*
|
|
17
|
+
* 🛑 Derived at SEED time from the members' own `mainImage`, not fetched. A
|
|
18
|
+
* card cannot resolve `bundleProductIds` into images without one query per
|
|
19
|
+
* bundle per render, and a grid of five bundles would then be five extra
|
|
20
|
+
* reads for decoration. `<ImageMosaic>` renders nothing below `MOSAIC_MIN`
|
|
21
|
+
* (3), so a short list degrades to the cover image rather than a broken
|
|
22
|
+
* collage.
|
|
23
|
+
*/
|
|
24
|
+
mosaicImages?: string[];
|
|
13
25
|
}
|
|
14
26
|
export interface CategoryMetrics {
|
|
15
27
|
productCount: number;
|
|
@@ -247,6 +247,34 @@ function bundleCategorySlugsFor(productIds) {
|
|
|
247
247
|
}
|
|
248
248
|
return out;
|
|
249
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Attach a bundle's member images so its card can render a collage.
|
|
252
|
+
*
|
|
253
|
+
* 🛑 A bundle IS its contents, so a single placeholder tile is the least
|
|
254
|
+
* informative thing a bundle card can show. Resolved here, at seed time, from
|
|
255
|
+
* the members' own `mainImage` — a card cannot do it at render time without one
|
|
256
|
+
* query per bundle, and a grid of five would be five reads for decoration.
|
|
257
|
+
*
|
|
258
|
+
* `<ImageMosaic>` renders nothing below `MOSAIC_MIN` (3), so a bundle with one
|
|
259
|
+
* or two resolvable members quietly falls back to its cover image rather than
|
|
260
|
+
* showing a broken collage. A member id that resolves to no product is dropped
|
|
261
|
+
* rather than defaulted — a wrong photo is worse than one fewer tile.
|
|
262
|
+
*/
|
|
263
|
+
function withBundleMosaic(row) {
|
|
264
|
+
const ids = row.bundleProductIds ?? [];
|
|
265
|
+
// `row.display` must already exist — `CategoryDocumentDisplay` requires
|
|
266
|
+
// `showInFooter`, so spreading onto `{}` would drop a required field. Every
|
|
267
|
+
// bundle row declares one; a row without is left untouched rather than
|
|
268
|
+
// half-built.
|
|
269
|
+
if (ids.length === 0 || !row.display)
|
|
270
|
+
return row;
|
|
271
|
+
const images = ids
|
|
272
|
+
.map((id) => productsStandardSeedData.find((p) => p.id === id)?.mainImage)
|
|
273
|
+
.filter((src) => typeof src === "string" && src.length > 0);
|
|
274
|
+
if (images.length === 0)
|
|
275
|
+
return row;
|
|
276
|
+
return { ...row, display: { ...row.display, mosaicImages: images } };
|
|
277
|
+
}
|
|
250
278
|
const bundleRows = [
|
|
251
279
|
{
|
|
252
280
|
id: "bundle-original-collectors-set",
|
|
@@ -514,7 +542,7 @@ const allCategoryRows = [
|
|
|
514
542
|
...sublistingRows.map((s) => ({ ancestors: [], ...s, createdByType: "admin" })),
|
|
515
543
|
...brandRows.map((b) => ({ ancestors: [], ...b, createdByType: "admin" })),
|
|
516
544
|
...franchiseRows.map((f) => ({ ancestors: [], ...f, createdByType: "admin" })),
|
|
517
|
-
...bundleRows.map((b) => ({ ancestors: [], ...b })),
|
|
545
|
+
...bundleRows.map((b) => ({ ancestors: [], ...withBundleMosaic(b) })),
|
|
518
546
|
]
|
|
519
547
|
.map(withGeneratedContent)
|
|
520
548
|
.map(withGeneratedCoverImage)
|