@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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.51.0",
4
- "head": "8e22d42a3cf524b03f860462c5fe567bfbb621de",
3
+ "version": "4.52.0",
4
+ "head": "5e6e4777891284de76aa0d646e556f8aee0ff76e",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-14T12:00:13.325Z"
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 ${isBrandView ? "brands" : "categories"} matching "${activeSearch}"` : isBrandView ? "No brands found" : "No categories found" }));
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
- // Tab state — "all" | "categories" | "brands" (not used when brandsOnly=true)
135
- const activeTab = brandsOnly ? "brands" : (table.get(TABLE_KEYS.TAB) || "all");
136
- const isBrandParam = brandsOnly ? true :
137
- activeTab === "brands" ? true :
138
- activeTab === "categories" ? false :
139
- undefined;
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.51.0",
3
+ "version": "4.52.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"