@mohasinac/appkit 4.8.0 → 4.9.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.
@@ -18,7 +18,7 @@ const STATUS_TONE_CLASSES = {
18
18
  };
19
19
  function RowAvatar({ image, alt, icon, size = "9" }) {
20
20
  const dims = size === "12" ? "h-12 w-12" : "h-9 w-9";
21
- return (_jsx(Div, { className: `relative ${dims} shrink-0 overflow-hidden`, rounded: "full", children: _jsx(MediaImage, { src: image, alt: alt, size: "avatar", fallback: icon }) }));
21
+ return (_jsx(Div, { className: `relative ${dims} shrink-0`, overflow: "hidden", rounded: "full", children: _jsx(MediaImage, { src: image, alt: alt, size: "avatar", fallback: icon }) }));
22
22
  }
23
23
  const FLAG_BADGES = [
24
24
  { key: "featured", label: "Featured", color: "bg-warning-surface text-warning dark:bg-warning-surface dark:text-warning" },
@@ -20,7 +20,7 @@ function buildDefaultColumns(resourceIcon) {
20
20
  {
21
21
  key: "primary",
22
22
  header: "Name",
23
- render: (row) => (_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Div, { className: "relative h-8 w-8 shrink-0 overflow-hidden", rounded: "full", children: _jsx(MediaImage, { src: typeof row.image === "string" ? row.image : undefined, alt: String(row.primary ?? ""), size: "avatar", fallback: resourceIcon }) }), _jsxs(Stack, { gap: "none", className: "min-w-0", children: [_jsx(Text, { weight: "medium", color: "primary", className: "truncate", children: String(row.primary ?? "") }), row.secondary ? _jsx(Text, { size: "xs", color: "muted", className: "truncate", children: String(row.secondary) }) : null] })] })),
23
+ render: (row) => (_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Div, { className: "relative h-8 w-8 shrink-0", overflow: "hidden", rounded: "full", children: _jsx(MediaImage, { src: typeof row.image === "string" ? row.image : undefined, alt: String(row.primary ?? ""), size: "avatar", fallback: resourceIcon }) }), _jsxs(Stack, { gap: "none", className: "min-w-0", children: [_jsx(Text, { weight: "medium", color: "primary", className: "truncate", children: String(row.primary ?? "") }), row.secondary ? _jsx(Text, { size: "xs", color: "muted", className: "truncate", children: String(row.secondary) }) : null] })] })),
24
24
  },
25
25
  {
26
26
  key: "status",
@@ -16,53 +16,59 @@ export async function CategoryDetailPageView({ slug }) {
16
16
  const category = await categoriesRepository
17
17
  .getCategoryBySlug(slug)
18
18
  .catch(() => undefined);
19
- // Use categorySlugs@=<id> (array-contains) products store category FK in
20
- // the categorySlugs[] array, not the legacy `category` string field.
21
- const catFilter = category?.id ? sieveFilter("categorySlugs", SIEVE_OP.CONTAINS, category.id) : null;
19
+ // Roll up descendant categories so a parent category page shows products
20
+ // filed under any of its children too, not just products tagged with the
21
+ // parent's own id parentIds stores the full ancestor chain, so a single
22
+ // array-contains query already returns the whole subtree (see
23
+ // categoriesRepository.getDescendantIds).
24
+ const descendantIds = category?.id
25
+ ? await categoriesRepository.getDescendantIds(category.id).catch(() => [])
26
+ : [];
27
+ const categoriesIn = category?.id ? [category.id, ...descendantIds] : null;
22
28
  const [productsResult, auctionsCountResult, preOrdersCountResult, prizeDrawsCountResult, bundlesResult, childCategories, rootSiblingCategories] = await Promise.all([
23
- catFilter
29
+ categoriesIn
24
30
  ? productRepository
25
31
  .list({
26
- filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), catFilter, sieveFilter("listingType", SIEVE_OP.EQ, "standard")),
32
+ filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "standard")),
27
33
  sorts: sortBy("createdAt", "DESC"),
28
34
  page: 1,
29
35
  pageSize: 24,
30
- })
36
+ }, { categoriesIn })
31
37
  .catch(() => null)
32
38
  : Promise.resolve(null),
33
- catFilter
39
+ categoriesIn
34
40
  ? productRepository
35
41
  .list({
36
- filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), catFilter, sieveFilter("listingType", SIEVE_OP.EQ, "auction")),
42
+ filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "auction")),
37
43
  sorts: sortBy("auctionEndDate", "ASC"),
38
44
  page: 1,
39
45
  pageSize: 1,
40
- })
46
+ }, { categoriesIn })
41
47
  .catch(() => null)
42
48
  : Promise.resolve(null),
43
- catFilter
49
+ categoriesIn
44
50
  ? productRepository
45
51
  .list({
46
- filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), catFilter, sieveFilter("listingType", SIEVE_OP.EQ, "pre-order")),
52
+ filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "pre-order")),
47
53
  sorts: sortBy("createdAt", "DESC"),
48
54
  page: 1,
49
55
  pageSize: 1,
50
- })
56
+ }, { categoriesIn })
51
57
  .catch(() => null)
52
58
  : Promise.resolve(null),
53
- catFilter
59
+ categoriesIn
54
60
  ? productRepository
55
61
  .list({
56
- filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), catFilter, sieveFilter("listingType", SIEVE_OP.EQ, "prize-draw")),
62
+ filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "prize-draw")),
57
63
  sorts: sortBy("createdAt", "DESC"),
58
64
  page: 1,
59
65
  pageSize: 1,
60
- })
66
+ }, { categoriesIn })
61
67
  .catch(() => null)
62
68
  : Promise.resolve(null),
63
69
  // SB-UNI-D — bundles fetched from the categories collection. We pull
64
70
  // all active bundle rows; the carousel filters by category affinity.
65
- catFilter
71
+ categoriesIn
66
72
  ? categoriesRepository
67
73
  .listByType("bundle", { activeOnly: true, limit: 50 })
68
74
  .catch(() => [])
@@ -112,8 +118,8 @@ export async function CategoryDetailPageView({ slug }) {
112
118
  averageRating: s.stats?.averageRating,
113
119
  createdAt: s.createdAt,
114
120
  }));
115
- const productCount = productsResult?.total ?? category?.metrics?.productCount ?? 0;
116
- const auctionCount = auctionsCountResult?.total ?? category?.metrics?.auctionCount ?? 0;
121
+ const productCount = productsResult?.total ?? category?.metrics?.totalProductCount ?? category?.metrics?.productCount ?? 0;
122
+ const auctionCount = auctionsCountResult?.total ?? category?.metrics?.totalAuctionCount ?? category?.metrics?.auctionCount ?? 0;
117
123
  const preOrderCount = preOrdersCountResult?.total ?? 0;
118
124
  const prizeDrawCount = prizeDrawsCountResult?.total ?? 0;
119
125
  const bundleCount = bundlesResult?.length ?? 0;
@@ -12,7 +12,11 @@ const __O = {
12
12
  };
13
13
  const CLS_FEATURED_DOT = "absolute left-2 top-2 rounded-full bg-warning-surface p-[var(--appkit-space-1)] leading-none";
14
14
  export function CategoryCard({ category, href, onClick, className = "", }) {
15
- const productCount = category.metrics?.productCount ?? category.productCount ?? 0;
15
+ // totalProductCount is the rollup (self + all descendant categories),
16
+ // maintained incrementally by onProductWrite — prefer it over the
17
+ // own-node-only productCount so a parent category's card shows the same
18
+ // "N products" a visitor actually sees once they open the page.
19
+ const productCount = category.metrics?.totalProductCount ?? category.metrics?.productCount ?? category.productCount ?? 0;
16
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" })] })] })] })] }));
17
21
  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}`;
18
22
  if (href) {
@@ -12,6 +12,14 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
12
12
  getCategoriesByTier(tier: number): Promise<CategoryDocument[]>;
13
13
  getCategoriesByRootId(rootId: string): Promise<CategoryDocument[]>;
14
14
  getChildren(parentId: string): Promise<CategoryDocument[]>;
15
+ /**
16
+ * IDs of every descendant category at any depth (not just direct
17
+ * children) — `parentIds` stores the full ancestor chain, so a single
18
+ * array-contains query on that field already returns the whole subtree
19
+ * with no recursion needed. Used to expand a parent category's product
20
+ * listing/count to include products filed under any of its children.
21
+ */
22
+ getDescendantIds(categoryId: string): Promise<string[]>;
15
23
  getFeaturedCategories(): Promise<CategoryDocument[]>;
16
24
  getBrandCategories(limit?: number): Promise<CategoryDocument[]>;
17
25
  updateMetrics(categoryId: string, productDelta: number, auctionDelta: number, productId?: string): Promise<void>;
@@ -161,6 +161,27 @@ export class CategoriesRepository extends BaseRepository {
161
161
  throw new DatabaseError(`Failed to retrieve children categories: ${error instanceof Error ? error.message : "Unknown error"}`);
162
162
  }
163
163
  }
164
+ /**
165
+ * IDs of every descendant category at any depth (not just direct
166
+ * children) — `parentIds` stores the full ancestor chain, so a single
167
+ * array-contains query on that field already returns the whole subtree
168
+ * with no recursion needed. Used to expand a parent category's product
169
+ * listing/count to include products filed under any of its children.
170
+ */
171
+ async getDescendantIds(categoryId) {
172
+ try {
173
+ const snapshot = await this.db
174
+ .collection(this.collection)
175
+ .where("parentIds", "array-contains", categoryId)
176
+ .limit(100)
177
+ .get();
178
+ return snapshot.docs.map((doc) => doc.id);
179
+ }
180
+ catch (error) {
181
+ void normalizeError(error);
182
+ throw new DatabaseError(`Failed to retrieve descendant categories: ${error instanceof Error ? error.message : "Unknown error"}`);
183
+ }
184
+ }
164
185
  async getFeaturedCategories() {
165
186
  try {
166
187
  const snapshot = await this.db
@@ -14,6 +14,10 @@ export interface CategoryDisplay {
14
14
  export interface CategoryMetrics {
15
15
  productCount: number;
16
16
  auctionCount?: number;
17
+ /** Rollup — this category's own productCount plus every descendant category's, maintained incrementally by onProductWrite. */
18
+ totalProductCount?: number;
19
+ /** Rollup — same as totalProductCount but for auctions. */
20
+ totalAuctionCount?: number;
17
21
  totalItemCount: number;
18
22
  lastUpdated?: string;
19
23
  }
@@ -22,7 +22,7 @@ function CategoryChip({ category }) {
22
22
  const iconSrc = category.display?.icon;
23
23
  const coverImage = category.display?.coverImage;
24
24
  const initial = category.name[0]?.toUpperCase() ?? "?";
25
- const productCount = category.metrics?.productCount ?? 0;
25
+ const productCount = category.metrics?.totalProductCount ?? category.metrics?.productCount ?? 0;
26
26
  return (_jsxs(Link, { href: ROUTES.PUBLIC.CATEGORY_DETAIL(category.slug), className: "group flex w-full min-h-[180px] sm:min-h-[220px] flex-col overflow-hidden rounded-xl border border-zinc-200 bg-[var(--appkit-color-surface)] shadow-sm transition-all hover:border-primary-300 hover:shadow-md border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] dark:hover:border-primary-600", children: [coverImage && isImageUrl(coverImage) ? (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, surface: "subtle", children: _jsx(Image, { src: resolveMediaUrl(coverImage), alt: category.name, width: 320, height: 180, className: "h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Div, { surface: "muted", className: "aspect-video w-full" })), _jsxs(Stack, { className: `flex-1 ${__P.p3} text-left`, children: [_jsx(Row, { textWeight: "bold", textSize: "sm", className: "mb-2 h-9 w-9 bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300", align: "center", justify: "center", rounded: "lg", children: iconSrc && isImageUrl(iconSrc) ? (_jsx(Image, { src: resolveMediaUrl(iconSrc), alt: "", width: 24, height: 24, className: "h-6 w-6 rounded object-cover", "aria-hidden": true })) : iconSrc ? (_jsx(Span, { size: "lg", "aria-hidden": "true", className: "leading-none", children: iconSrc })) : (initial) }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", truncate: 2, children: category.name }), _jsxs(Text, { className: "mt-1", color: "muted", size: "xs", children: [productCount.toLocaleString(), " items"] }), _jsx(Text, { className: "mt-auto text-primary dark:text-primary-400 pt-[0.75rem]", size: "xs", weight: "medium", children: "Browse category \u2192" })] })] }));
27
27
  }
28
28
  const CTA_CLASSES = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.8.0",
3
+ "version": "4.9.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"