@mohasinac/appkit 4.64.0 → 4.65.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.
Files changed (163) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.js +18 -1
  3. package/dist/_internal/server/features/search/index-build/sources.js +27 -12
  4. package/dist/_internal/server/features/seo/sitemap.js +78 -12
  5. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  6. package/dist/_internal/server/features/site-settings/adapters.js +16 -1
  7. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  8. package/dist/_internal/server/features/stores/adapters.js +14 -0
  9. package/dist/_internal/server/features/stores/tabs.d.ts +19 -15
  10. package/dist/_internal/server/features/stores/tabs.js +20 -21
  11. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +9 -4
  12. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +22 -0
  13. package/dist/_internal/server/jobs/core/countersReconcile.js +0 -1
  14. package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +1 -1
  15. package/dist/_internal/shared/features/site-settings/types.d.ts +10 -2
  16. package/dist/_internal/shared/features/sticker-trace/trace/segment.js +13 -2
  17. package/dist/_internal/shared/listing-types/feature-flags.d.ts +54 -0
  18. package/dist/_internal/shared/listing-types/feature-flags.js +72 -0
  19. package/dist/client.d.ts +5 -2
  20. package/dist/client.js +8 -1
  21. package/dist/constants/field-names.d.ts +1 -0
  22. package/dist/constants/field-names.js +14 -0
  23. package/dist/features/account/api/[userId]/addresses/route.js +1 -5
  24. package/dist/features/addresses/repository/addresses.repository.js +1 -1
  25. package/dist/features/admin/api/bids/route.js +1 -5
  26. package/dist/features/admin/api/coupons/route.js +1 -5
  27. package/dist/features/admin/api/products/route.js +1 -5
  28. package/dist/features/admin/api/reviews/route.js +1 -5
  29. package/dist/features/admin/components/AdminCarouselEditorView.js +0 -1
  30. package/dist/features/admin/components/AdminFeatureEditorView.js +1 -2
  31. package/dist/features/admin/components/AdminGuideHubView.js +1 -1
  32. package/dist/features/admin/components/AdminProductEditorView.js +4 -1
  33. package/dist/features/admin/components/AdminSiteSettingsView.js +19 -3
  34. package/dist/features/admin/components/OwnerQuickActions.d.ts +7 -1
  35. package/dist/features/admin/components/OwnerQuickActions.js +1 -1
  36. package/dist/features/admin/schemas/admin-editor-forms.js +0 -1
  37. package/dist/features/admin/schemas/firestore.d.ts +52 -0
  38. package/dist/features/admin/schemas/firestore.js +20 -0
  39. package/dist/features/auctions/api/route.js +1 -8
  40. package/dist/features/auctions/components/AuctionDetailPageView.js +1 -7
  41. package/dist/features/auth/hooks/useAuth.js +1 -1
  42. package/dist/features/before-after/api/route.js +1 -5
  43. package/dist/features/blog/api/route.js +1 -8
  44. package/dist/features/blog/components/BlogIndexPageView.d.ts +2 -3
  45. package/dist/features/blog/components/BlogIndexPageView.js +6 -9
  46. package/dist/features/cart/actions/cart-actions.js +1 -1
  47. package/dist/features/cart/api/route.js +1 -5
  48. package/dist/features/cart/utils/guest-cart.js +1 -5
  49. package/dist/features/catalogue/repository/catalogue.repository.js +1 -1
  50. package/dist/features/categories/api/route.js +5 -23
  51. package/dist/features/categories/components/BundlesListView.d.ts +2 -3
  52. package/dist/features/categories/components/BundlesListView.js +2 -5
  53. package/dist/features/categories/components/CategoriesIndexListing.js +47 -5
  54. package/dist/features/categories/components/CategoriesIndexPageView.d.ts +2 -3
  55. package/dist/features/categories/components/CategoriesIndexPageView.js +0 -32
  56. package/dist/features/categories/constants/category-tabs.d.ts +1 -1
  57. package/dist/features/categories/constants/category-tabs.js +7 -0
  58. package/dist/features/categories/hooks/useCategories.d.ts +6 -0
  59. package/dist/features/categories/hooks/useCategories.js +73 -2
  60. package/dist/features/categories/types/index.d.ts +1 -1
  61. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -8
  62. package/dist/features/collections/api/route.js +1 -5
  63. package/dist/features/consultation/api/route.js +1 -5
  64. package/dist/features/contact/email.js +2 -5
  65. package/dist/features/corporate/api/route.js +1 -5
  66. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +1 -7
  67. package/dist/features/events/components/AdminEventEditorView.js +1 -1
  68. package/dist/features/events/components/EventsListPageView.d.ts +2 -3
  69. package/dist/features/events/components/EventsListPageView.js +8 -11
  70. package/dist/features/faq/constants/help-guides.d.ts +8 -5
  71. package/dist/features/faq/constants/help-guides.js +9 -5
  72. package/dist/features/filters/AsyncFacetSection.js +2 -2
  73. package/dist/features/filters/FilterFacetSection.js +5 -5
  74. package/dist/features/filters/RangeFilter.js +2 -2
  75. package/dist/features/filters/SwitchFilter.js +2 -2
  76. package/dist/features/filters/filterUtils.d.ts +0 -2
  77. package/dist/features/filters/filterUtils.js +0 -4
  78. package/dist/features/filters/index.d.ts +1 -1
  79. package/dist/features/filters/index.js +1 -1
  80. package/dist/features/forms/Checkbox.js +4 -3
  81. package/dist/features/forms/Input.js +5 -4
  82. package/dist/features/forms/Radio.js +6 -5
  83. package/dist/features/forms/Select.js +3 -2
  84. package/dist/features/forms/Slider.js +3 -2
  85. package/dist/features/forms/Textarea.js +3 -2
  86. package/dist/features/forms/Toggle.js +3 -3
  87. package/dist/features/forms/index.d.ts +1 -1
  88. package/dist/features/forms/index.js +1 -1
  89. package/dist/features/forms/utils.d.ts +0 -2
  90. package/dist/features/forms/utils.js +0 -4
  91. package/dist/features/grouped/schemas/grouped-listing-form.d.ts +6 -6
  92. package/dist/features/grouped/schemas/index.d.ts +2 -2
  93. package/dist/features/history/utils/guest-history.js +1 -5
  94. package/dist/features/homepage/components/CustomCardsSection.js +8 -2
  95. package/dist/features/homepage/components/FeaturedBundlesSection.d.ts +6 -1
  96. package/dist/features/homepage/components/FeaturedBundlesSection.js +1 -1
  97. package/dist/features/layout/AppLayoutShell.d.ts +23 -1
  98. package/dist/features/layout/AppLayoutShell.js +13 -12
  99. package/dist/features/layout/BottomNavItem.d.ts +9 -1
  100. package/dist/features/layout/BottomNavItem.js +18 -4
  101. package/dist/features/layout/index.d.ts +1 -1
  102. package/dist/features/live/components/LiveItemDetailPageView.js +1 -7
  103. package/dist/features/media/MediaLightbox.js +3 -5
  104. package/dist/features/orders/components/OrderCompletionPanel.d.ts +1 -1
  105. package/dist/features/orders/components/OrderCompletionPanel.js +1 -1
  106. package/dist/features/orders/components/RaiseDisputeForm.js +1 -1
  107. package/dist/features/orders/components/RefundRequestView.js +1 -1
  108. package/dist/features/parts/schemas/firestore.d.ts +274 -0
  109. package/dist/features/parts/schemas/firestore.js +19 -0
  110. package/dist/features/payments/repository/saved-payment-methods.repository.js +1 -1
  111. package/dist/features/pre-orders/api/admin/route.js +1 -5
  112. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -7
  113. package/dist/features/products/api/route.js +1 -3
  114. package/dist/features/products/components/CustomSectionTabContent.js +0 -5
  115. package/dist/features/products/components/GroupMemberPicker.js +2 -2
  116. package/dist/features/products/components/GroupSettingsPanel.js +1 -1
  117. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -7
  118. package/dist/features/products/components/PrizeDrawEntryActions.d.ts +1 -1
  119. package/dist/features/products/components/PrizeDrawEntryActions.js +1 -1
  120. package/dist/features/products/components/ProductDetailPageView.js +1 -7
  121. package/dist/features/products/repository/products.repository.js +0 -1
  122. package/dist/features/promotions/api/route.js +1 -8
  123. package/dist/features/reviews/components/ReviewsIndexPageView.d.ts +2 -3
  124. package/dist/features/reviews/components/ReviewsIndexPageView.js +9 -12
  125. package/dist/features/seller/api/coupons/route.js +1 -5
  126. package/dist/features/seller/api/products/route.js +1 -5
  127. package/dist/features/seller/components/SellerProductShell.js +1 -2
  128. package/dist/features/seller/hooks/useSellerListingData.js +0 -30
  129. package/dist/features/stores/api/[storeSlug]/auctions/route.js +1 -10
  130. package/dist/features/stores/api/[storeSlug]/products/route.js +1 -10
  131. package/dist/features/stores/components/InteractiveStoreCard.js +9 -1
  132. package/dist/features/stores/components/StoreGuideHubView.js +1 -1
  133. package/dist/features/stores/components/StoresIndexPageView.d.ts +2 -3
  134. package/dist/features/stores/components/StoresIndexPageView.js +6 -9
  135. package/dist/features/stores/schemas/firestore.d.ts +27 -0
  136. package/dist/features/stores/schemas/firestore.js +2 -0
  137. package/dist/features/wishlist/utils/guest-wishlist.js +1 -5
  138. package/dist/index.d.ts +4 -1
  139. package/dist/index.js +28 -1
  140. package/dist/providers/db-firebase/admin-app-lite.js +14 -2
  141. package/dist/providers/db-firebase/admin.js +8 -2
  142. package/dist/react/hooks/useBrowsePageFlags.d.ts +26 -0
  143. package/dist/react/hooks/useBrowsePageFlags.js +18 -0
  144. package/dist/schemas/registry.d.ts +4 -4
  145. package/dist/security/csp.d.ts +32 -2
  146. package/dist/security/csp.js +32 -2
  147. package/dist/styles.css +1 -1
  148. package/dist/tailwind-utilities.css +1 -1
  149. package/dist/ui/components/Button.js +1 -1
  150. package/dist/ui/components/Chart.js +0 -1
  151. package/dist/ui/rich-text/RichText.js +1 -3
  152. package/dist/ui/rich-text/RichTextRenderer.js +1 -3
  153. package/dist/utils/browser-storage.d.ts +14 -0
  154. package/dist/utils/browser-storage.js +18 -0
  155. package/dist/utils/classnames.d.ts +19 -0
  156. package/dist/utils/classnames.js +21 -0
  157. package/dist/utils/search-params.d.ts +44 -0
  158. package/dist/utils/search-params.js +46 -0
  159. package/dist/utils/sieve-builder.d.ts +13 -0
  160. package/dist/utils/sieve-builder.js +22 -0
  161. package/dist/utils/string.formatter.d.ts +12 -0
  162. package/dist/utils/string.formatter.js +17 -0
  163. package/package.json +2 -2
@@ -13,14 +13,7 @@ import { BLOG_DEFAULT_PUBLIC_SORT } from "../schemas/firestore.js";
13
13
  import { NextResponse } from "next/server.js";
14
14
  import { getProviders } from "../../../contracts/index.js";
15
15
  import { normalizeError } from "../../../errors/normalize.js";
16
- function param(url, key) {
17
- return url.searchParams.get(key);
18
- }
19
- function numParam(url, key, fallback) {
20
- const v = url.searchParams.get(key);
21
- const n = v !== null ? Number(v) : NaN;
22
- return Number.isFinite(n) ? n : fallback;
23
- }
16
+ import { numParam, param } from "../../../utils/search-params.js";
24
17
  // --- GET /api/blog ------------------------------------------------------------
25
18
  export async function GET(request) {
26
19
  try {
@@ -1,7 +1,6 @@
1
1
  import React from "react";
2
- type SearchParams = Record<string, string | string[]>;
2
+ import type { SearchParamsRecord } from "../../../utils/search-params";
3
3
  export interface BlogIndexPageViewProps {
4
- searchParams?: SearchParams;
4
+ searchParams?: SearchParamsRecord;
5
5
  }
6
6
  export declare function BlogIndexPageView({ searchParams }: BlogIndexPageViewProps): Promise<React.JSX.Element>;
7
- export {};
@@ -6,15 +6,12 @@ import { BlogIndexListing } from "./BlogIndexListing.js";
6
6
  import { BLOG_DEFAULT_PUBLIC_SORT } from "../schemas/firestore.js";
7
7
  import { safeRead } from "../../../errors/safe-read.js";
8
8
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
9
- function sp(params, key) {
10
- const v = params[key];
11
- return Array.isArray(v) ? v[0] ?? "" : v ?? "";
12
- }
9
+ import { firstParam } from "../../../utils/search-params.js";
13
10
  export async function BlogIndexPageView({ searchParams = {} }) {
14
- const sort = sp(searchParams, "sort") || BLOG_DEFAULT_PUBLIC_SORT;
15
- const page = Number(sp(searchParams, "page")) || 1;
16
- const pageSize = Number(sp(searchParams, "pageSize")) || 24;
17
- const category = sp(searchParams, "category");
11
+ const sort = firstParam(searchParams, "sort") || BLOG_DEFAULT_PUBLIC_SORT;
12
+ const page = Number(firstParam(searchParams, "page")) || 1;
13
+ const pageSize = Number(firstParam(searchParams, "pageSize")) || 24;
14
+ const category = firstParam(searchParams, "category");
18
15
  /*
19
16
  * 🛑 `q` MUST be read and forwarded. Without it /blog?q=anything rendered the
20
17
  * FULL published list — the same failure the API route's own header records
@@ -23,7 +20,7 @@ export async function BlogIndexPageView({ searchParams = {} }) {
23
20
  * `initialData`, and the listing hook sets `staleTime: Infinity` when given
24
21
  * SSR data, so the unfiltered first paint is frozen for that query key.
25
22
  */
26
- const q = sp(searchParams, "q").trim();
23
+ const q = firstParam(searchParams, "q").trim();
27
24
  const sieveResult = await safeRead(() => blogRepository.listPublished({ ...(category ? { category } : {}), ...(q ? { search: q } : {}) }, { sorts: sort, page, pageSize }), { route: "/blog", key: "blogPosts.listPublished", fallback: null });
28
25
  // Sandbox posts are public rows by design — not on someone else's first paint.
29
26
  const publicSieve = sieveResult
@@ -11,7 +11,7 @@ import { productRepository } from "../../products/repository/products.repository
11
11
  import { storeRepository } from "../../stores/repository/store.repository.js";
12
12
  import { normalizeListingType } from "../../products/utils/listing-type.js";
13
13
  import { getDefaultCurrency } from "../../../core/baseline-resolver.js";
14
- import { CART_LANE, activeLane, canAddNewItems, } from "../../../_internal/shared/checkout/lanes.js";
14
+ import { CART_LANE, activeLane, canAddNewItems } from "../../../_internal/shared/checkout/lanes.js";
15
15
  import { CART_MAX_ITEMS } from "../../../constants/limits.js";
16
16
  import { getExpandedDecrements, validateCartItemStock, } from "../../../_internal/server/features/checkout/bundle-expansion.js";
17
17
  import { safeRead } from "../../../errors/safe-read.js";
@@ -11,11 +11,7 @@
11
11
  import { NextResponse } from "next/server.js";
12
12
  import { getProviders } from "../../../contracts/index.js";
13
13
  import { normalizeError } from "../../../errors/normalize.js";
14
- function numParam(url, key, fallback) {
15
- const v = url.searchParams.get(key);
16
- const n = v !== null ? Number(v) : NaN;
17
- return Number.isFinite(n) ? n : fallback;
18
- }
14
+ import { numParam } from "../../../utils/search-params.js";
19
15
  export async function GET(request) {
20
16
  try {
21
17
  const url = new URL(request.url);
@@ -1,5 +1,6 @@
1
1
  import { normalizeError } from "../../../errors/normalize.js";
2
2
  import { CART_MAX_ITEMS } from "../../../constants/limits.js";
3
+ import { getDefaultStorage } from "../../../utils/browser-storage.js";
3
4
  const DEFAULT_GUEST_CART_KEY = process.env.NEXT_PUBLIC_APP_ID
4
5
  ? `${process.env.NEXT_PUBLIC_APP_ID}_guest_cart`
5
6
  : "guest_cart";
@@ -24,11 +25,6 @@ export class CartFullError extends Error {
24
25
  this.current = current;
25
26
  }
26
27
  }
27
- function getDefaultStorage() {
28
- if (typeof window === "undefined")
29
- return null;
30
- return window.localStorage;
31
- }
32
28
  function readItems(storage, key) {
33
29
  if (!storage)
34
30
  return [];
@@ -2,7 +2,7 @@
2
2
  * Personal Catalogue Repository (Feature B).
3
3
  */
4
4
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase/index.js";
5
- import { CATALOGUE_COLLECTION, CATALOGUE_FIELDS, CATALOGUE_TRACKED_FIELDS, CATALOGUE_HISTORY_PII_FIELDS, createCatalogueItemId, } from "../schemas/firestore.js";
5
+ import { CATALOGUE_COLLECTION, CATALOGUE_FIELDS, CATALOGUE_TRACKED_FIELDS, CATALOGUE_HISTORY_PII_FIELDS, createCatalogueItemId } from "../schemas/firestore.js";
6
6
  import { withHistory } from "../../../_internal/shared/history/index.js";
7
7
  export class CatalogueRepository extends BaseRepository {
8
8
  /** Decrypt on read so callers see the shape they wrote. */
@@ -23,6 +23,8 @@ import { isListingCategory } from "../constants/listing-categories.js";
23
23
  import { planSearchTxt } from "../../../utils/search-txt-query.js";
24
24
  import { matchesAllSearchTerms } from "../../../utils/search-txt.js";
25
25
  import { normalizeError } from "../../../errors/normalize.js";
26
+ import { intParamOrNull, param } from "../../../utils/search-params.js";
27
+ import { validateSieveFilters } from "../../../utils/sieve-builder.js";
26
28
  const CACHE_CONTROL_PUBLIC = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
27
29
  /**
28
30
  * Overlay the LIVE rolled-up count onto a projected page of categories.
@@ -80,30 +82,10 @@ function buildTreeFromFlat(items, rootId) {
80
82
  }
81
83
  return roots.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map(nest);
82
84
  }
83
- function param(url, key) {
84
- return url.searchParams.get(key);
85
- }
86
- function numParam(url, key) {
87
- const v = url.searchParams.get(key);
88
- if (v === null)
89
- return null;
90
- const n = Number.parseInt(v, 10);
91
- return Number.isFinite(n) ? n : null;
92
- }
93
85
  const SAFE_CATEGORY_FILTER_FIELDS = new Set([
94
86
  "categoryType", "parentIds", "isFeatured", "showOnHomepage",
95
87
  "tier", "isActive", "isLeaf", "isBrand", "isSearchable",
96
88
  ]);
97
- function validateSieveFilters(raw, allowedFields) {
98
- return raw
99
- .split(",")
100
- .map((c) => c.trim())
101
- .filter((c) => {
102
- const m = c.match(/^([^<>=!@]+)\s*(?:==|!=|<=|>=|<|>|@=\*?)/);
103
- return m ? allowedFields.has(m[1].trim()) : false;
104
- })
105
- .join(",");
106
- }
107
89
  // --- GET /api/categories ------------------------------------------------------
108
90
  export async function GET(request) {
109
91
  try {
@@ -118,8 +100,8 @@ export async function GET(request) {
118
100
  const flat = param(url, "flat");
119
101
  const tree = param(url, "tree");
120
102
  const rawFilters = param(url, "filters");
121
- const tier = numParam(url, "tier");
122
- const pageSizeParam = numParam(url, "pageSize");
103
+ const tier = intParamOrNull(url, "tier");
104
+ const pageSizeParam = intParamOrNull(url, "pageSize");
123
105
  /*
124
106
  * 🛑 `?page=` MEANS "give me a paged flat list", and reading it fixes two
125
107
  * live bugs rather than adding a feature.
@@ -139,7 +121,7 @@ export async function GET(request) {
139
121
  * were permanently empty. Same shape as the `useCategoryTree` bug:
140
122
  * `isFiltered` did not include the params real callers send.
141
123
  */
142
- const pageParam = numParam(url, "page");
124
+ const pageParam = intParamOrNull(url, "page");
143
125
  const isPaged = pageParam !== null && pageParam > 0;
144
126
  /*
145
127
  * `?listingOnly=true` drops brand / bundle / sublisting rows.
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
- type SearchParams = Record<string, string | string[]>;
2
+ import type { SearchParamsRecord } from "../../../utils/search-params";
3
3
  export interface BundlesListViewProps {
4
- searchParams?: SearchParams;
4
+ searchParams?: SearchParamsRecord;
5
5
  onBuyNow?: (input: {
6
6
  bundleSlug: string;
7
7
  quantity: number;
@@ -15,4 +15,3 @@ export interface BundlesListViewProps {
15
15
  * Sort UI is owned by `CategoryBundlesListing` (client).
16
16
  */
17
17
  export declare function BundlesListView({ searchParams, onBuyNow, }: BundlesListViewProps): Promise<React.JSX.Element>;
18
- export {};
@@ -5,11 +5,8 @@ import { AdSlot } from "../../homepage/components/AdSlot.js";
5
5
  import { CategoryBundlesListing } from "./CategoryBundlesListing.js";
6
6
  import { safeRead } from "../../../errors/safe-read.js";
7
7
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
8
+ import { firstParam } from "../../../utils/search-params.js";
8
9
  const DEFAULT_LIMIT = 200;
9
- function sp(params, key) {
10
- const v = params[key];
11
- return Array.isArray(v) ? v[0] ?? "" : v ?? "";
12
- }
13
10
  /**
14
11
  * Public `/bundles` index. Bundles live as `categoryType:"bundle"` rows on
15
12
  * the categories collection (SB-UNI-D + V), so this fetches via
@@ -18,7 +15,7 @@ function sp(params, key) {
18
15
  * Sort UI is owned by `CategoryBundlesListing` (client).
19
16
  */
20
17
  export async function BundlesListView({ searchParams = {}, onBuyNow, }) {
21
- const storeId = sp(searchParams, "storeId");
18
+ const storeId = firstParam(searchParams, "storeId");
22
19
  const all = await safeRead(() => categoriesRepository.listByType("bundle", {
23
20
  activeOnly: true,
24
21
  limit: DEFAULT_LIMIT,
@@ -20,7 +20,6 @@ const __O = {
20
20
  const PAGE_SIZE = 24;
21
21
  function renderCategoryGrid(props) {
22
22
  const { isLoading, categories, view, activeSearch, activeTab, brandsOnly } = props;
23
- const isBrandView = activeTab === "brands" || brandsOnly;
24
23
  /* The empty state names the tab the visitor is actually on. It said
25
24
  * "categories" on all five, so an empty Parts tab reported the wrong thing. */
26
25
  const noun = brandsOnly
@@ -155,12 +154,21 @@ export function CategoriesIndexListing({ brandsOnly = false }) {
155
154
  const switchTab = (key) => {
156
155
  table.set(TABLE_KEYS.TAB, key);
157
156
  };
158
- const { categories, totalPages, isLoading } = useCategoriesFiltered({
157
+ const { categories, allRows, totalPages, isLoading } = useCategoriesFiltered({
159
158
  q: table.get(TABLE_KEYS.QUERY) || undefined,
160
159
  isFeatured: table.get(TABLE_KEYS.IS_FEATURED) === "true" || undefined,
161
160
  isBrand: isBrandParam,
162
- // The tab partition — applied before pagination inside the hook.
163
- predicate: (c) => matchesCategoryTab(c, activeTab),
161
+ /*
162
+ * The tab partition — applied before pagination inside the hook.
163
+ *
164
+ * 🛑 `useCallback` on `activeTab` is REQUIRED, not hygiene. The hook
165
+ * memoises its filter/sort/slice on this reference; an inline arrow is a
166
+ * new reference every render, so it would recompute on each one. (The
167
+ * converse was the actual bug: the predicate was missing from the hook's
168
+ * dependency list entirely, so a tab change recomputed NOTHING and the
169
+ * previous tab's rows stayed on screen.)
170
+ */
171
+ predicate: useCallback((c) => matchesCategoryTab(c, activeTab), [activeTab]),
164
172
  rootOnly: table.get("rootOnly") === "true" || undefined,
165
173
  tier: getNumericParam(table, "tier"),
166
174
  minProductCount: getNumericParam(table, "minItemCount"),
@@ -186,7 +194,41 @@ export function CategoriesIndexListing({ brandsOnly = false }) {
186
194
  * navigate. That is also why the items carry `onSelect` rather than `href`,
187
195
  * and therefore why `linkProps`/`scroll` do not apply here.
188
196
  */
189
- const tabItems = CATEGORY_TABS.map((tab) => ({
197
+ /*
198
+ * 🛑 A TAB THAT CAN ONLY EVER BE EMPTY IS THE SAME DEAD FACET AS AN EMPTY CARD.
199
+ *
200
+ * This index already removed its Parts tab for being "the worse half of two
201
+ * better homes", and `category-tabs.ts` records that *"every tab answers a
202
+ * question; 'all' answered none"*. A tab whose answer is always "nothing"
203
+ * answers none either — it is a labelled promise of content that opens on
204
+ * "No characters found".
205
+ *
206
+ * It became load-bearing the moment 320 character rows were seeded `isActive:
207
+ * false`: the type is real, the rows exist and resolve, and NOT ONE is live
208
+ * until a product carries a character slug. Shipping the tab regardless
209
+ * would have put a permanently empty section in the site's main taxonomy
210
+ * index — and then quietly filled itself the day someone flipped a flag,
211
+ * with no second commit needed. That is the behaviour worth having, so the
212
+ * tab is DERIVED from the payload rather than from a list.
213
+ *
214
+ * 🛑 Counted from `allRows` — the whole cached response — never from
215
+ * `categories`, which is this tab's own paginated slice and would make every
216
+ * inactive tab hide itself. Root Cause #72 is the trap here ("a 'hide it when
217
+ * it's empty' rule is only as good as its count map"), and the reason this
218
+ * version is safe is that there IS no map: both the count and the render
219
+ * partition one payload through the same `matchesCategoryTab`, so a tab
220
+ * cannot be uncovered by construction.
221
+ *
222
+ * Two rows that must not be collapsed into the count:
223
+ * - WHILE LOADING, `allRows` is empty, so every tab would vanish and
224
+ * reappear. The bar renders in full until the payload lands.
225
+ * - THE ACTIVE TAB IS NEVER HIDDEN. A direct link to `?tab=characters`
226
+ * must not leave the visitor on a bar with no selected tab; it shows the
227
+ * tab and its honest empty state.
228
+ */
229
+ const tabItems = CATEGORY_TABS.filter((tab) => isLoading ||
230
+ tab.key === activeTab ||
231
+ allRows.some((c) => matchesCategoryTab(c, tab.key))).map((tab) => ({
190
232
  id: tab.key,
191
233
  label: tab.label,
192
234
  onSelect: () => switchTab(tab.key),
@@ -1,11 +1,11 @@
1
1
  import React from "react";
2
- type SearchParams = Record<string, string | string[]>;
2
+ import type { SearchParamsRecord } from "../../../utils/search-params";
3
3
  export interface CategoriesIndexPageViewProps {
4
4
  /**
5
5
  * @deprecated Accepted and ignored. Kept so the consumer page shim compiles
6
6
  * either way; remove once no caller passes it.
7
7
  */
8
- searchParams?: SearchParams;
8
+ searchParams?: SearchParamsRecord;
9
9
  }
10
10
  /**
11
11
  * The categories index.
@@ -30,4 +30,3 @@ export interface CategoriesIndexPageViewProps {
30
30
  * lose (contrast `/products`, where the grid IS in the SSR HTML and must stay).
31
31
  */
32
32
  export declare function CategoriesIndexPageView(_props?: CategoriesIndexPageViewProps): Promise<React.JSX.Element>;
33
- export {};
@@ -1,39 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
3
2
  import { Container, Heading, Main, Section } from "../../../ui/index.js";
4
3
  import { AdSlot } from "../../homepage/components/AdSlot.js";
5
4
  import { CategoriesIndexListing } from "./CategoriesIndexListing.js";
6
- function sp(params, key) {
7
- const v = params[key];
8
- return Array.isArray(v) ? v[0] ?? "" : v ?? "";
9
- }
10
- function buildCategoryFilters(params) {
11
- const parts = [];
12
- const isFeatured = sp(params, "isFeatured");
13
- if (isFeatured === "true")
14
- parts.push("isFeatured==true");
15
- const isBrand = sp(params, "isBrand");
16
- if (isBrand === "true")
17
- parts.push("isBrand==true");
18
- const rootOnly = sp(params, "rootOnly");
19
- if (rootOnly === "true")
20
- parts.push("tier==0");
21
- const minItemCount = sp(params, "minItemCount");
22
- const maxItemCount = sp(params, "maxItemCount");
23
- if (minItemCount)
24
- parts.push(sieveFilter("metrics.totalItemCount", SIEVE_OP.GTE, minItemCount));
25
- if (maxItemCount)
26
- parts.push(sieveFilter("metrics.totalItemCount", SIEVE_OP.LTE, maxItemCount));
27
- const tier = sp(params, "tier");
28
- if (tier) {
29
- const values = tier.split("|").filter(Boolean);
30
- if (values.length === 1)
31
- parts.push(sieveFilter("tier", SIEVE_OP.EQ, values[0]));
32
- else if (values.length > 1)
33
- parts.push(sieveFilter("tier", SIEVE_OP.EQ, values.join("|")));
34
- }
35
- return parts.join(",");
36
- }
37
5
  /**
38
6
  * The categories index.
39
7
  *
@@ -39,7 +39,7 @@
39
39
  * @tag pattern:shared-literal
40
40
  * @tag sideEffects:none
41
41
  */
42
- export type CategoryTabKey = "categories" | "brands" | "franchises" | "bundles";
42
+ export type CategoryTabKey = "categories" | "brands" | "franchises" | "bundles" | "characters";
43
43
  export interface CategoryTabDef {
44
44
  key: CategoryTabKey;
45
45
  label: string;
@@ -48,6 +48,11 @@ export const CATEGORY_TABS = [
48
48
  { key: "categories", label: "Categories" },
49
49
  { key: "brands", label: "Brands" },
50
50
  { key: "franchises", label: "Franchises" },
51
+ /*
52
+ * Who USED the bey. Answers a question none of the others can and the one
53
+ * buyers actually ask — "Tyson's Dragoon", not "Attack Ring MA-04".
54
+ */
55
+ { key: "characters", label: "Characters" },
51
56
  { key: "bundles", label: "Bundles" },
52
57
  ];
53
58
  export const DEFAULT_CATEGORY_TAB = "categories";
@@ -66,6 +71,8 @@ export function matchesCategoryTab(c, tab) {
66
71
  return type === "brand";
67
72
  case "franchises":
68
73
  return type === "franchise";
74
+ case "characters":
75
+ return type === "character";
69
76
  case "bundles":
70
77
  return type === "bundle";
71
78
  case "categories":
@@ -32,12 +32,18 @@ export interface CategoriesFilteredParams {
32
32
  * Not part of the react-query key, deliberately — the FETCH is the same
33
33
  * unfiltered `?flat=true` for every tab, so keying on it would mint five
34
34
  * cache entries for one response.
35
+ *
36
+ * 🛑 That applies to the QUERY KEY ONLY. It must still be a dependency of the
37
+ * in-memory filter/sort memo, and omitting it there was a live bug: a tab
38
+ * change altered nothing else the memo watched, so it returned the previous
39
+ * tab's rows. Caller supplies it via `useCallback` on the tab key.
35
40
  */
36
41
  predicate?: (c: CategoryItem) => boolean;
37
42
  }
38
43
  /** API-driven for structured filters; text search + pagination done in hook (Firestore substring limitation). */
39
44
  export declare function useCategoriesFiltered(params?: CategoriesFilteredParams): {
40
45
  categories: CategoryItem[];
46
+ allRows: CategoryItem[];
41
47
  total: number;
42
48
  totalPages: number;
43
49
  isLoading: boolean;
@@ -38,8 +38,57 @@ export function useCategoriesFiltered(params = {}) {
38
38
  staleTime: 2 * 60 * 1000,
39
39
  });
40
40
  const { page = 1, pageSize = 24, sort = "name", q = "", minProductCount, maxProductCount } = params;
41
+ /*
42
+ * Every LIVE row in the payload — before any tab partition, search or
43
+ * pagination. Exposed because a caller that partitions this response into
44
+ * tabs needs to know whether a tab has anything in it at all, and its own
45
+ * `categories` slice cannot tell it that (it is one tab's page).
46
+ *
47
+ * 🛑 Deliberately NOT narrowed by `q`. A tab bar that loses tabs as the
48
+ * visitor types is worse than one that keeps a tab whose search found
49
+ * nothing — the second has an honest empty state, the first makes the
50
+ * section look deleted.
51
+ */
52
+ const allRows = useMemo(() => {
53
+ const items = query.data ?? [];
54
+ /*
55
+ * 🛑 AN INACTIVE ROW IS A DEAD FACET, AND UNTIL 2026-10-24 NOTHING ENFORCED IT.
56
+ *
57
+ * `brand-rows.ts` trap 3 states the rule — *"a row with no product is a
58
+ * DEAD FACET, and worse than absent: it renders as a card in the grid that
59
+ * opens an empty page"* — and `franchise-rows.ts` claims its seven inactive
60
+ * rows are therefore *"excluded from the grid and from search"*.
61
+ *
62
+ * **They were not.** `GET /api/categories` builds its Sieve string from
63
+ * `type` / `parentIds` / `isFeatured` / `isBrand` / `showOnHomepage` /
64
+ * `tier` only, and `useCategoriesFiltered` sends nothing but `flat=true`,
65
+ * so no layer ever added an `isActive` clause. All seven inactive
66
+ * franchises have been rendering on the Franchises tab, each one a card
67
+ * opening an empty page. The promise was in a comment; the mechanism did
68
+ * not exist (Root Cause #106).
69
+ *
70
+ * It surfaced when 320 inactive CHARACTER rows were seeded on the strength
71
+ * of that promise — seven dead cards is easy to miss, 320 is not.
72
+ *
73
+ * 🛑 IN MEMORY, AND ONLY ON AN EXPLICIT `false`. Two reasons, both of which
74
+ * a server-side clause would get wrong:
75
+ *
76
+ * - A Firestore inequality EXCLUDES every document that lacks the field.
77
+ * `isActive` is optional on `CategoryDocument`, so pushing this down
78
+ * would hide every row that never set it — which is most of the real
79
+ * taxonomy. That is the documented trap in CLAUDE.md's TS-patterns
80
+ * table, in the one shape that returns a plausible non-empty list.
81
+ * - The same reason `isActive?: boolean` is declared on `CategoryItem` at
82
+ * all: absent must mean "live", never "unknown".
83
+ *
84
+ * ADMIN IS NOT AFFECTED. `/admin/categories` reads this route through
85
+ * `useAdminListingData`, not this hook, and must keep seeing inactive rows
86
+ * — they are what it offers a Status column to flip.
87
+ */
88
+ return items.filter((c) => c.isActive !== false);
89
+ }, [query.data]);
41
90
  const paginated = useMemo(() => {
42
- let items = query.data ?? [];
91
+ let items = allRows;
43
92
  const lq = q.toLowerCase();
44
93
  if (lq)
45
94
  items = items.filter((c) => c.name.toLowerCase().includes(lq) || (c.description ?? "").toLowerCase().includes(lq));
@@ -83,9 +132,31 @@ export function useCategoriesFiltered(params = {}) {
83
132
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
84
133
  const slice = items.slice((page - 1) * pageSize, page * pageSize);
85
134
  return { items: slice, total, totalPages };
86
- }, [query.data, q, sort, page, pageSize, minProductCount, maxProductCount]);
135
+ /*
136
+ * 🛑 `params.predicate` BELONGS IN THIS LIST, and leaving it out was a live
137
+ * bug on the categories index — not a theoretical lint complaint.
138
+ *
139
+ * The predicate IS the tab partition. `switchTab` writes only the `tab` URL
140
+ * param, so on a tab change `q`, `sort`, `page` and `pageSize` are all
141
+ * unchanged, and the request string is identical for every tab except
142
+ * brands (the one that sends `isBrand=true`) — so react-query hands back
143
+ * the SAME `query.data` reference. With the predicate absent from the deps,
144
+ * nothing in them changed, the memo returned its cached slice, and
145
+ * **switching Categories → Franchises → Bundles re-rendered the previous
146
+ * tab's rows.** Only the brands tab worked, by accident, because its query
147
+ * key differs.
148
+ *
149
+ * It is a changing reference by nature, so the call site must `useCallback`
150
+ * it on the tab key — `CategoriesIndexListing` now does. Were it ever
151
+ * passed inline again the memo would recompute every render, which is
152
+ * merely wasteful over ~1,500 in-memory rows and still CORRECT; the
153
+ * previous behaviour was neither.
154
+ */
155
+ }, [allRows, q, sort, page, pageSize, minProductCount, maxProductCount, params.predicate, params.isBrand]);
87
156
  return {
88
157
  categories: paginated.items,
158
+ /* Every live row, unpartitioned — see `allRows` above. */
159
+ allRows,
89
160
  total: paginated.total,
90
161
  totalPages: paginated.totalPages,
91
162
  isLoading: query.isLoading,
@@ -1,4 +1,4 @@
1
- export type CategoryType = "category" | "sublisting" | "brand" | "bundle" | "franchise";
1
+ export type CategoryType = "category" | "sublisting" | "brand" | "bundle" | "franchise" | "character";
2
2
  export interface CategorySeo {
3
3
  title?: string;
4
4
  description?: string;
@@ -7,7 +7,7 @@ const CLS_CLASSIFIED_BADGE = "inline-block rounded-full bg-cyan-100 dark:bg-cyan
7
7
  import { ROUTES } from "../../../next/routing/route-map.js";
8
8
  import { getDefaultCurrency } from "../../../core/baseline-resolver.js";
9
9
  import { formatCurrency } from "../../../utils/number.formatter.js";
10
- import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
10
+ import { toDescriptionHtml } from "../../../utils/string.formatter.js";
11
11
  import { safeDisplayName } from "../../../security/index.js";
12
12
  import { Container, Div, GatedPrice, Heading, Main, Nav, RichText, Row, Section, Span, Stack, Text, SpecTable, } from "../../../ui/index.js";
13
13
  import { PreOrderDetailView } from "../../products/components/PreOrderDetailView.js";
@@ -25,12 +25,6 @@ import { safeRead } from "../../../errors/safe-read.js";
25
25
  import { DEFAULT_MIN_OFFER_PERCENT, offersEnabledFor, resolveOfferBounds, } from "../../../_internal/shared/features/offers/config.js";
26
26
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
27
27
  import { productImageUrls } from "../../products/schemas/firestore.js";
28
- function toDescriptionHtml(raw) {
29
- if (!raw)
30
- return "";
31
- const s = typeof raw === "string" ? raw : JSON.stringify(raw);
32
- return normalizeRichTextHtml(s);
33
- }
34
28
  export async function ClassifiedDetailPageView({ slug, initialProduct, renderOfferAction }) {
35
29
  const product = initialProduct !== undefined
36
30
  ? (initialProduct ?? undefined)
@@ -65,7 +59,7 @@ export async function ClassifiedDetailPageView({ slug, initialProduct, renderOff
65
59
  const storeHref = storeSlug ? String(ROUTES.PUBLIC.STORE_DETAIL(storeSlug)) : null;
66
60
  const specs = Array.isArray(p.specifications) ? p.specifications : [];
67
61
  const descriptionHtml = toDescriptionHtml(p.description);
68
- const [{ relatedItems, relatedByBrand, relatedByTags, relatedByStore }, groups, siteSettings] = await Promise.all([
62
+ const [{ relatedItems, relatedByBrand, relatedByTags, relatedByStore }, groups] = await Promise.all([
69
63
  computeRelatedItems(product),
70
64
  getGroupsWithItemsForProduct(product.id),
71
65
  safeRead(() => getSiteSettingsGlobal(), {
@@ -11,11 +11,7 @@
11
11
  import { NextResponse } from "next/server.js";
12
12
  import { getProviders } from "../../../contracts/index.js";
13
13
  import { normalizeError } from "../../../errors/normalize.js";
14
- function numParam(url, key, fallback) {
15
- const v = url.searchParams.get(key);
16
- const n = v !== null ? Number(v) : NaN;
17
- return Number.isFinite(n) ? n : fallback;
18
- }
14
+ import { numParam } from "../../../utils/search-params.js";
19
15
  export async function GET(request) {
20
16
  try {
21
17
  const url = new URL(request.url);
@@ -11,11 +11,7 @@
11
11
  import { NextResponse } from "next/server.js";
12
12
  import { getProviders } from "../../../contracts/index.js";
13
13
  import { normalizeError } from "../../../errors/normalize.js";
14
- function numParam(url, key, fallback) {
15
- const v = url.searchParams.get(key);
16
- const n = v !== null ? Number(v) : NaN;
17
- return Number.isFinite(n) ? n : fallback;
18
- }
14
+ import { numParam } from "../../../utils/search-params.js";
19
15
  export async function GET(request) {
20
16
  try {
21
17
  const url = new URL(request.url);
@@ -18,17 +18,14 @@ import { getProviders } from "../../contracts/index.js";
18
18
  import { createResendProvider } from "../../providers/email-resend/provider.js";
19
19
  import { siteSettingsRepository } from "../admin/repository/site-settings.repository.js";
20
20
  import { serverLogger } from "../../monitoring/index.js";
21
- import { currentYear, formatCurrency, } from "../../utils/index.js";
22
- import { EmailBold, EmailButton, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader, EmailRow, } from "../email/primitives.js";
21
+ import { currentYear, formatCurrency } from "../../utils/index.js";
22
+ import { EmailBold, EmailButton, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader, EmailRow } from "../email/primitives.js";
23
23
  function getSiteName() {
24
24
  return process.env.NEXT_PUBLIC_SITE_NAME?.trim() || "App";
25
25
  }
26
26
  function getSiteUrl() {
27
27
  return process.env.NEXTAUTH_URL?.trim() || "http://localhost:3000";
28
28
  }
29
- function getSupportEmail() {
30
- return process.env.EMAIL_SUPPORT?.trim() || "support@example.com";
31
- }
32
29
  /**
33
30
  * Resolve an email provider that works in BOTH runtimes.
34
31
  *
@@ -11,11 +11,7 @@
11
11
  import { NextResponse } from "next/server.js";
12
12
  import { getProviders } from "../../../contracts/index.js";
13
13
  import { normalizeError } from "../../../errors/normalize.js";
14
- function numParam(url, key, fallback) {
15
- const v = url.searchParams.get(key);
16
- const n = v !== null ? Number(v) : NaN;
17
- return Number.isFinite(n) ? n : fallback;
18
- }
14
+ import { numParam } from "../../../utils/search-params.js";
19
15
  export async function GET(request) {
20
16
  try {
21
17
  const url = new URL(request.url);
@@ -7,7 +7,7 @@ const CLS_DIGITALCODE_BADGE = "inline-block rounded-full bg-violet-100 dark:bg-v
7
7
  import { ROUTES } from "../../../next/routing/route-map.js";
8
8
  import { getDefaultCurrency } from "../../../core/baseline-resolver.js";
9
9
  import { formatCurrency } from "../../../utils/number.formatter.js";
10
- import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
10
+ import { toDescriptionHtml } from "../../../utils/string.formatter.js";
11
11
  import { safeDisplayName } from "../../../security/index.js";
12
12
  import { Div, Container, GatedPrice, Heading, Main, Nav, RichText, Row, Section, Span, Stack, Text, SpecTable, } from "../../../ui/index.js";
13
13
  import { PreOrderDetailView } from "../../products/components/PreOrderDetailView.js";
@@ -24,12 +24,6 @@ import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPane
24
24
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
25
25
  import { productImageUrls } from "../../products/schemas/firestore.js";
26
26
  import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
27
- function toDescriptionHtml(raw) {
28
- if (!raw)
29
- return "";
30
- const s = typeof raw === "string" ? raw : JSON.stringify(raw);
31
- return normalizeRichTextHtml(s);
32
- }
33
27
  export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPrimaryActions }) {
34
28
  const product = initialProduct !== undefined
35
29
  ? (initialProduct ?? undefined)
@@ -503,7 +503,7 @@ export function AdminEventEditorView({ eventId, onSaved, embedded, allowedTypes,
503
503
  // Replaces the hand-rolled fieldToStepIndex + goToStep pair. The hook owns
504
504
  // the expand-then-scroll ordering a raw setState cannot: a collapsed panel
505
505
  // must MOUNT before scrollIntoView and focus can reach the offending field.
506
- const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, draft);
506
+ const { openIds, setOpenIds } = useSectionFormNav(sections, draft);
507
507
  const formContent = (_jsxs(Stack, { gap: "sm", children: [alertSection, _jsxs(FormShellContext.Provider, { value: shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: update, onSubmit: () => { saveMutation.mutate(); }, schema: eventDraftSchema, openIds: openIds, onOpenChange: setOpenIds, submitLabel: isEdit ? "Save Changes" : "Create Event", isLoading: isLoading })] }), saveMessage && (_jsx(Alert, { variant: saveMessage.toLowerCase().includes("fail") || saveMessage.toLowerCase().includes("error") ? "error" : "success", title: "Save status", children: saveMessage }))] }));
508
508
  if (embedded) {
509
509
  return _jsx(Div, { className: `${__O.yAuto} ${__P.p4}`, children: formContent });