@mohasinac/appkit 4.42.18 → 4.44.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 (263) hide show
  1. package/dist/_internal/client/features/lottery/LotteryDetailView.js +22 -1
  2. package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
  3. package/dist/_internal/server/features/categories/adapters.js +235 -0
  4. package/dist/_internal/server/features/checkout/actions.js +161 -22
  5. package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
  6. package/dist/_internal/server/features/orders/adapters.js +21 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +24 -0
  8. package/dist/_internal/server/features/products/list-public.js +10 -2
  9. package/dist/_internal/server/features/refunds/actions.js +1 -1
  10. package/dist/_internal/server/features/seo/sitemap.js +54 -15
  11. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/stores/adapters.js +6 -0
  13. package/dist/_internal/server/functions/scheduled.d.ts +4 -2
  14. package/dist/_internal/server/functions/scheduled.js +64 -6
  15. package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
  16. package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
  17. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
  18. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
  19. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
  20. package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
  21. package/dist/_internal/server/jobs/core/onCategoryWrite.js +59 -0
  22. package/dist/_internal/server/jobs/core/onProductWrite.js +31 -0
  23. package/dist/_internal/server/jobs/core/onReviewWrite.js +8 -0
  24. package/dist/_internal/server/jobs/core/onStoreWrite.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/onStoreWrite.js +8 -2
  26. package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
  27. package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
  28. package/dist/_internal/server/jobs/core/revalidateNotify.d.ts +28 -0
  29. package/dist/_internal/server/jobs/core/revalidateNotify.js +125 -0
  30. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
  31. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
  32. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
  33. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
  34. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
  35. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
  36. package/dist/_internal/server/jobs/handlers/index.js +3 -1
  37. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
  38. package/dist/_internal/server/jobs/handlers/messages.js +5 -2
  39. package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
  40. package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
  41. package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
  42. package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
  43. package/dist/_internal/shared/actions/action-registry.js +34 -0
  44. package/dist/_internal/shared/cache/revalidate-targets.d.ts +49 -0
  45. package/dist/_internal/shared/cache/revalidate-targets.js +157 -0
  46. package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
  47. package/dist/_internal/shared/checkout/order-math.js +194 -7
  48. package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
  49. package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
  50. package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
  51. package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
  52. package/dist/_internal/shared/fees/calculator.d.ts +105 -0
  53. package/dist/_internal/shared/fees/calculator.js +143 -0
  54. package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
  55. package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
  56. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
  57. package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
  58. package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
  59. package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
  60. package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
  61. package/dist/_internal/shared/templating/placeholders.js +73 -0
  62. package/dist/client.d.ts +2 -0
  63. package/dist/client.js +1 -0
  64. package/dist/constants/api-endpoints.d.ts +42 -0
  65. package/dist/constants/api-endpoints.js +14 -0
  66. package/dist/features/admin/actions/admin-actions.d.ts +1 -1
  67. package/dist/features/admin/actions/admin-actions.js +1 -1
  68. package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
  69. package/dist/features/admin/components/AdminProductEditorView.js +8 -1
  70. package/dist/features/admin/components/AdminProductsView.js +78 -7
  71. package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
  72. package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
  73. package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
  74. package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
  75. package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
  76. package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
  77. package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
  78. package/dist/features/auctions/components/AuctionBottomActions.js +10 -1
  79. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  80. package/dist/features/auctions/components/AuctionLiveStatusBadge.d.ts +20 -0
  81. package/dist/features/auctions/components/AuctionLiveStatusBadge.js +21 -0
  82. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
  83. package/dist/features/auctions/components/PlaceBidFormClient.js +20 -2
  84. package/dist/features/auctions/hooks/useIsAuctionEnded.d.ts +41 -0
  85. package/dist/features/auctions/hooks/useIsAuctionEnded.js +76 -0
  86. package/dist/features/auth/schemas/firestore.d.ts +0 -1
  87. package/dist/features/blog/api/route.js +16 -1
  88. package/dist/features/blog/schemas/firestore.d.ts +2 -2
  89. package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
  90. package/dist/features/categories/actions/category-actions.d.ts +2 -2
  91. package/dist/features/categories/actions/category-actions.js +22 -10
  92. package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
  93. package/dist/features/categories/api/[id]/description-template/route.js +252 -0
  94. package/dist/features/categories/api/route.js +156 -11
  95. package/dist/features/categories/components/CategoriesIndexListing.d.ts +1 -3
  96. package/dist/features/categories/components/CategoriesIndexListing.js +1 -1
  97. package/dist/features/categories/components/CategoriesIndexPageView.d.ts +27 -1
  98. package/dist/features/categories/components/CategoriesIndexPageView.js +24 -33
  99. package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
  100. package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
  101. package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
  102. package/dist/features/categories/constants/listing-categories.d.ts +17 -0
  103. package/dist/features/categories/constants/listing-categories.js +53 -0
  104. package/dist/features/categories/hooks/useCategoryTree.js +28 -2
  105. package/dist/features/categories/repository/categories.repository.d.ts +73 -12
  106. package/dist/features/categories/repository/categories.repository.js +203 -42
  107. package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
  108. package/dist/features/categories/repository/price-index.repository.js +85 -0
  109. package/dist/features/categories/schemas/category-content.d.ts +206 -0
  110. package/dist/features/categories/schemas/category-content.js +28 -0
  111. package/dist/features/categories/schemas/firestore.d.ts +74 -5
  112. package/dist/features/categories/schemas/firestore.js +74 -3
  113. package/dist/features/categories/schemas/index.d.ts +1047 -0
  114. package/dist/features/categories/schemas/index.js +96 -0
  115. package/dist/features/categories/server.d.ts +1 -0
  116. package/dist/features/categories/server.js +7 -0
  117. package/dist/features/categories/types/index.d.ts +9 -0
  118. package/dist/features/events/schemas/firestore.d.ts +2 -2
  119. package/dist/features/faq/schemas/firestore.d.ts +2 -1
  120. package/dist/features/faq/schemas/firestore.js +20 -8
  121. package/dist/features/faq/utils/getHomepageFaqsCached.d.ts +20 -0
  122. package/dist/features/faq/utils/getHomepageFaqsCached.js +22 -0
  123. package/dist/features/homepage/components/MarketplaceHomepageView.js +13 -3
  124. package/dist/features/homepage/lib/live-stats.js +18 -8
  125. package/dist/features/media/MediaImage.js +19 -2
  126. package/dist/features/orders/schemas/firestore.d.ts +70 -0
  127. package/dist/features/orders/types/index.d.ts +55 -0
  128. package/dist/features/products/actions/product-actions.d.ts +0 -9
  129. package/dist/features/products/actions/product-actions.js +25 -0
  130. package/dist/features/products/columns/index.d.ts +0 -1
  131. package/dist/features/products/columns/index.js +5 -1
  132. package/dist/features/products/components/ArtStickersListView.js +7 -1
  133. package/dist/features/products/components/PrizeDrawsSection.js +5 -0
  134. package/dist/features/products/components/ProductFilters.d.ts +0 -9
  135. package/dist/features/products/components/ProductForm.d.ts +27 -1
  136. package/dist/features/products/components/ProductForm.js +26 -12
  137. package/dist/features/products/components/ProductsIndexPageView.js +1 -1
  138. package/dist/features/products/constants/sieve.d.ts +0 -6
  139. package/dist/features/products/constants/sieve.js +35 -2
  140. package/dist/features/products/repository/products.repository.d.ts +115 -1
  141. package/dist/features/products/repository/products.repository.js +491 -39
  142. package/dist/features/products/schemas/firestore.d.ts +65 -5
  143. package/dist/features/products/schemas/firestore.js +9 -0
  144. package/dist/features/products/schemas/product-features.d.ts +140 -2
  145. package/dist/features/products/schemas/product-features.js +62 -1
  146. package/dist/features/promotions/repository/coupons.repository.js +6 -1
  147. package/dist/features/promotions/schemas/firestore.d.ts +44 -1
  148. package/dist/features/promotions/schemas/firestore.js +76 -2
  149. package/dist/features/reviews/repository/reviews.repository.d.ts +22 -0
  150. package/dist/features/reviews/repository/reviews.repository.js +35 -0
  151. package/dist/features/seller/actions/seller-actions.js +17 -2
  152. package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
  153. package/dist/features/seller/components/PrintCenterView.js +3 -2
  154. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
  155. package/dist/features/seller/components/SellerOrdersView.js +6 -1
  156. package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
  157. package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
  158. package/dist/features/seller/components/SellerProductShell.js +101 -9
  159. package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
  160. package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
  161. package/dist/features/seller/components/SellerProductsView.js +108 -3
  162. package/dist/features/seller/types/index.d.ts +17 -0
  163. package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
  164. package/dist/features/seller/utils/product-draft-mapping.js +17 -0
  165. package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
  166. package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
  167. package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
  168. package/dist/features/stores/schemas/firestore.d.ts +31 -2
  169. package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
  170. package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
  171. package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
  172. package/dist/features/tax-codes/schemas/firestore.js +82 -0
  173. package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
  174. package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
  175. package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
  176. package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
  177. package/dist/features/tester/actions/index.d.ts +0 -1
  178. package/dist/features/tester/actions/index.js +0 -1
  179. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
  180. package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
  181. package/dist/features/tester/components/index.d.ts +0 -4
  182. package/dist/features/tester/components/index.js +0 -2
  183. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
  184. package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
  185. package/dist/features/tester/schemas/firestore.d.ts +0 -6
  186. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
  187. package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
  188. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
  189. package/dist/features/tester/seed-data/authored/index.js +0 -2
  190. package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
  191. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +10 -29
  192. package/dist/index.d.ts +21 -7
  193. package/dist/index.js +53 -7
  194. package/dist/next/routing/route-map.d.ts +2 -4
  195. package/dist/next/routing/route-map.js +1 -2
  196. package/dist/repositories/index.d.ts +1 -0
  197. package/dist/repositories/index.js +1 -0
  198. package/dist/schemas/registry.d.ts +506 -0
  199. package/dist/seed/_helpers/brand-rows.d.ts +35 -0
  200. package/dist/seed/_helpers/brand-rows.js +576 -0
  201. package/dist/seed/_helpers/category-content.d.ts +39 -0
  202. package/dist/seed/_helpers/category-content.js +634 -0
  203. package/dist/seed/_helpers/category-forest.d.ts +2 -0
  204. package/dist/seed/_helpers/category-forest.js +585 -243
  205. package/dist/seed/_helpers/category-models.d.ts +5 -0
  206. package/dist/seed/_helpers/category-models.js +1364 -0
  207. package/dist/seed/_helpers/category-tree.js +17 -1
  208. package/dist/seed/_helpers/character-features.d.ts +10 -0
  209. package/dist/seed/_helpers/character-features.js +118 -0
  210. package/dist/seed/_helpers/media.d.ts +1 -1
  211. package/dist/seed/_helpers/media.js +44 -8
  212. package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
  213. package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
  214. package/dist/seed/categories-seed-data.js +147 -169
  215. package/dist/seed/index.d.ts +1 -0
  216. package/dist/seed/index.js +1 -0
  217. package/dist/seed/manifest.d.ts +1 -1
  218. package/dist/seed/manifest.js +5 -0
  219. package/dist/seed/orders-seed-data.js +5 -3
  220. package/dist/seed/product-features-seed-data.d.ts +1 -8
  221. package/dist/seed/product-features-seed-data.js +358 -154
  222. package/dist/seed/products-art-seed-data.js +16 -4
  223. package/dist/seed/products-auctions-seed-data.js +6 -6
  224. package/dist/seed/products-classifieds-seed-data.js +17 -7
  225. package/dist/seed/products-digital-codes-seed-data.js +12 -12
  226. package/dist/seed/products-preorders-seed-data.js +8 -8
  227. package/dist/seed/products-prize-draws-seed-data.js +25 -8
  228. package/dist/seed/products-standard-seed-data.js +10 -60
  229. package/dist/seed/products-stickers-seed-data.js +6 -6
  230. package/dist/seed/site-settings-seed-data.js +24 -0
  231. package/dist/seed/store-extensions-seed-data.js +10 -4
  232. package/dist/seed/tax-codes-seed-data.d.ts +34 -0
  233. package/dist/seed/tax-codes-seed-data.js +100 -0
  234. package/dist/seed/test-data-ttl.d.ts +29 -0
  235. package/dist/seed/test-data-ttl.js +31 -0
  236. package/dist/server-entry.d.ts +1 -1
  237. package/dist/server-entry.js +6 -1
  238. package/dist/server.d.ts +4 -1
  239. package/dist/server.js +11 -2
  240. package/dist/styles.css +40 -0
  241. package/dist/ui/components/PaginatedSelect.d.ts +14 -0
  242. package/dist/ui/components/PaginatedSelect.js +0 -0
  243. package/dist/ui/components/PaginatedSelect.style.css +40 -0
  244. package/dist/utils/media-url.d.ts +26 -0
  245. package/dist/utils/media-url.js +144 -0
  246. package/dist/utils/physical-location.d.ts +23 -0
  247. package/dist/utils/physical-location.js +52 -0
  248. package/dist/utils/search-txt-builders.d.ts +20 -0
  249. package/dist/utils/search-txt-builders.js +36 -0
  250. package/package.json +1 -1
  251. package/scripts/seed-cli.mjs +4 -1
  252. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
  253. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
  254. package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
  255. package/dist/features/tester/actions/leaderboard-actions.js +0 -5
  256. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
  257. package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
  258. package/dist/features/tester/components/TesterHubView.d.ts +0 -5
  259. package/dist/features/tester/components/TesterHubView.js +0 -110
  260. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
  261. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
  262. package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
  263. package/dist/features/tester/seed-data/tester-ttl.js +0 -6
@@ -38,8 +38,12 @@ import { useMediaUpload } from "../../media";
38
38
  import { MediaUploadList } from "../../media/upload/MediaUploadList";
39
39
  import { createCatalogueItemSchema } from "../schemas/validation";
40
40
  /** PUBLIC endpoints — this is a buyer surface; the admin ones would 403. */
41
- async function loadOptionsFrom(endpoint, query, page) {
42
- const params = new URLSearchParams({ page: String(page), pageSize: "25" });
41
+ async function loadOptionsFrom(endpoint, query, page,
42
+ /** Extra query params. Passed as an object rather than baked into
43
+ * `endpoint`, because this builds its own `?` and a pre-existing query
44
+ * string would produce `...?listingOnly=true?page=1`. */
45
+ extra) {
46
+ const params = new URLSearchParams({ page: String(page), pageSize: "25", ...extra });
43
47
  if (query)
44
48
  params.set("q", query);
45
49
  const res = await apiClient.get(`${endpoint}?${params.toString()}`);
@@ -49,7 +53,14 @@ async function loadOptionsFrom(endpoint, query, page) {
49
53
  hasMore: data.hasMore ?? false,
50
54
  };
51
55
  }
52
- const loadCategoryOptions = (q, p) => loadOptionsFrom(CATEGORY_ENDPOINTS.LIST, q, p);
56
+ /*
57
+ * 🛑 `listingOnly=true` is load-bearing. Without it this picker offered brand
58
+ * and bundle rows as categories — measured against production: "Takara-Tomy",
59
+ * "Original Collector's Set", "Metal Fusion Duo". They share the `categories`
60
+ * collection and a plain listing category omits `categoryType`, so the test
61
+ * cannot be a query clause; the route applies it in memory.
62
+ */
63
+ const loadCategoryOptions = (q, p) => loadOptionsFrom(CATEGORY_ENDPOINTS.LIST, q, p, { listingOnly: "true" });
53
64
  const loadBrandOptions = (q, p) => loadOptionsFrom(BRAND_ENDPOINTS.LIST, q, p);
54
65
  export function CatalogueItemEditorView({ item, onSaved }) {
55
66
  const isCreate = !item;
@@ -9,8 +9,8 @@ export declare function listCategories(params?: {
9
9
  page?: number;
10
10
  pageSize?: number;
11
11
  }): Promise<FirebaseSieveResult<CategoryDocument>>;
12
- export declare function listTopLevelCategories(limit?: number): Promise<CategoryDocument[]>;
13
- export declare function listBrandCategories(limit?: number): Promise<CategoryDocument[]>;
12
+ export declare const listTopLevelCategories: (limit?: number) => Promise<CategoryDocument[]>;
13
+ export declare const listBrandCategories: (limit?: number) => Promise<CategoryDocument[]>;
14
14
  export declare function getCategoryById(id: string): Promise<CategoryDocument | null>;
15
15
  export declare function getCategoryBySlug(slug: string): Promise<CategoryDocument | null>;
16
16
  export declare function getCategoryChildren(parentId: string): Promise<CategoryDocument[]>;
@@ -1,3 +1,4 @@
1
+ import { cache } from "react";
1
2
  import { serverLogger } from "../../../monitoring";
2
3
  import { categoriesRepository } from "../repository/categories.repository";
3
4
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
@@ -39,19 +40,30 @@ export async function listCategories(params) {
39
40
  };
40
41
  return categoriesRepository.list(sieve);
41
42
  }
42
- export async function listTopLevelCategories(limit = 12) {
43
- const all = await categoriesRepository.getCategoriesByTier(0);
43
+ /*
44
+ * Both of these fetched the ENTIRE matching set and `.slice()`d it to 12, on
45
+ * every homepage render. The bound is now pushed into Firestore (with headroom
46
+ * for the post-fetch filters), and both are `cache()`-wrapped so a second reader
47
+ * in the same request tree — the nav, a footer, a sibling section — costs
48
+ * nothing. Measured 2026-10-09 against a 50K-reads/day free tier.
49
+ *
50
+ * The in-memory `isActive !== false` filter is gone: both queries already
51
+ * require `isActive == true`, and a Firestore equality excludes documents
52
+ * lacking the field, so it could never match anything the query had not already
53
+ * admitted. `hidePublicTestData` stays — that one is a real post-filter, which
54
+ * is why the repository fetches with headroom rather than exactly `limit`.
55
+ */
56
+ export const listTopLevelCategories = cache(async (limit = 12) => {
57
+ const all = await categoriesRepository.getCategoriesByTier(0, limit);
44
58
  return hidePublicTestData(all)
45
- .filter((c) => c.isActive !== false)
46
59
  .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
47
60
  .slice(0, limit);
48
- }
49
- export async function listBrandCategories(limit = 12) {
50
- const brands = await categoriesRepository.getBrandCategories();
51
- return hidePublicTestData(brands)
52
- .filter((c) => c.isActive !== false)
53
- .slice(0, limit);
54
- }
61
+ });
62
+ export const listBrandCategories = cache(async (limit = 12) => {
63
+ // Over-fetch a small margin so `hidePublicTestData` cannot under-fill the row.
64
+ const brands = await categoriesRepository.getBrandCategories(limit + 8);
65
+ return hidePublicTestData(brands).slice(0, limit);
66
+ });
55
67
  export async function getCategoryById(id) {
56
68
  return categoriesRepository.findById(id);
57
69
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * feat-categories — GET /api/categories/[id]/description-template
3
+ *
4
+ * The ONE read the seller form makes when a category or condition changes.
5
+ *
6
+ * ```ts
7
+ * // app/api/categories/[id]/description-template/route.ts
8
+ * export { categoryDescriptionTemplateGET as GET } from "@mohasinac/appkit";
9
+ * ```
10
+ *
11
+ * ## Why an endpoint rather than letting the client walk the tree
12
+ *
13
+ * A description template lives on the category and INHERITS: a named-model
14
+ * leaf like `category-dragoon-g` carries no body of its own, and the right one
15
+ * is on the `category-original-engine-gear` line above it. Resolving that on
16
+ * the client would be one request per ancestor, on every category change, from
17
+ * a form — up to five round trips to answer one question.
18
+ *
19
+ * Here it is **two reads worst case and zero on a cache hit**: the leaf, then
20
+ * one bounded `getAll` over at most four ancestors, behind a one-hour edge
21
+ * cache. The response depends only on `(id, variant)` and category content
22
+ * changes when an admin edits it, so an hour of staleness costs a seller
23
+ * nothing and saves a function invocation per keystroke-free form open.
24
+ *
25
+ * ## 🛑 NO MONEY IN THIS PAYLOAD
26
+ *
27
+ * `Cache-Control: public` means a CDN holds this and anyone can fetch it, so
28
+ * `priceGuidance` is deliberately stripped even though it sits in the same
29
+ * `productDefaults` object. The band is competitor-derived market data, not a
30
+ * listing price — but prices are withheld from signed-out visitors precisely
31
+ * because hiding one number is pointless if a neighbouring surface publishes
32
+ * the range, and a publicly-cached endpoint is exactly such a surface. The
33
+ * seller's price hint is C1's job, from a surface that knows who is asking.
34
+ *
35
+ * `taxCodeId` is stripped for a different reason: nothing on the client reads
36
+ * it. The rate is resolved server-side by `deriveTaxonomy`, which is the whole
37
+ * point of the reference being server-resolved (C2a) — a client that knew the
38
+ * code could not do anything correct with it.
39
+ */
40
+ import { NextResponse } from "next/server.js";
41
+ export declare function GET(request: Request, context: {
42
+ params: Promise<{
43
+ id: string;
44
+ }>;
45
+ }): Promise<NextResponse>;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * feat-categories — GET /api/categories/[id]/description-template
3
+ *
4
+ * The ONE read the seller form makes when a category or condition changes.
5
+ *
6
+ * ```ts
7
+ * // app/api/categories/[id]/description-template/route.ts
8
+ * export { categoryDescriptionTemplateGET as GET } from "@mohasinac/appkit";
9
+ * ```
10
+ *
11
+ * ## Why an endpoint rather than letting the client walk the tree
12
+ *
13
+ * A description template lives on the category and INHERITS: a named-model
14
+ * leaf like `category-dragoon-g` carries no body of its own, and the right one
15
+ * is on the `category-original-engine-gear` line above it. Resolving that on
16
+ * the client would be one request per ancestor, on every category change, from
17
+ * a form — up to five round trips to answer one question.
18
+ *
19
+ * Here it is **two reads worst case and zero on a cache hit**: the leaf, then
20
+ * one bounded `getAll` over at most four ancestors, behind a one-hour edge
21
+ * cache. The response depends only on `(id, variant)` and category content
22
+ * changes when an admin edits it, so an hour of staleness costs a seller
23
+ * nothing and saves a function invocation per keystroke-free form open.
24
+ *
25
+ * ## 🛑 NO MONEY IN THIS PAYLOAD
26
+ *
27
+ * `Cache-Control: public` means a CDN holds this and anyone can fetch it, so
28
+ * `priceGuidance` is deliberately stripped even though it sits in the same
29
+ * `productDefaults` object. The band is competitor-derived market data, not a
30
+ * listing price — but prices are withheld from signed-out visitors precisely
31
+ * because hiding one number is pointless if a neighbouring surface publishes
32
+ * the range, and a publicly-cached endpoint is exactly such a surface. The
33
+ * seller's price hint is C1's job, from a surface that knows who is asking.
34
+ *
35
+ * `taxCodeId` is stripped for a different reason: nothing on the client reads
36
+ * it. The rate is resolved server-side by `deriveTaxonomy`, which is the whole
37
+ * point of the reference being server-resolved (C2a) — a client that knew the
38
+ * code could not do anything correct with it.
39
+ */
40
+ import { NextResponse } from "next/server.js";
41
+ import { normalizeError } from "../../../../../errors/normalize";
42
+ import { serverLogger } from "../../../../../monitoring";
43
+ /*
44
+ * The repository directly, which is the established shape for an appkit API
45
+ * route (blog/[slug], products/[id] and seller/products all do the same). The
46
+ * `getProviders()` contract exposes only the generic `IRepository`, so the
47
+ * bounded `findByIds` ancestor walk this route needs is not reachable through
48
+ * it — and the alternative is four sequential reads on a route the seller form
49
+ * hits on every category change.
50
+ */
51
+ import { categoriesRepository } from "../../../repository/categories.repository";
52
+ import { usedPlaceholders } from "../../../../../_internal/shared/templating/placeholders";
53
+ /**
54
+ * The variants a caller may ask for.
55
+ *
56
+ * 🛑 Validated against the union rather than passed through. An unrecognised
57
+ * variant must fall back to `default` and not reach a `templates.find()` that
58
+ * would answer `null` — because `null` here means "this category has no
59
+ * template", which the picker renders as "No template yet". A typo'd variant
60
+ * would therefore look like missing content rather than a bad request, and
61
+ * nobody would ever find it.
62
+ */
63
+ const VARIANTS = [
64
+ "default",
65
+ "new_in_box",
66
+ "new_in_packet",
67
+ "pre_owned",
68
+ "replica",
69
+ ];
70
+ /** At most four ancestors on a five-tier tree, so the walk is bounded by shape. */
71
+ const MAX_ANCESTORS = 8;
72
+ function toPublicDefaults(d) {
73
+ if (!d)
74
+ return {};
75
+ /*
76
+ * An explicit allow-list, not a spread-and-delete. Root Cause #70: a
77
+ * deny-list keyed on a TypeScript interface is structurally blind to fields
78
+ * the interface does not declare, and this object gains fields over time.
79
+ */
80
+ const out = {};
81
+ if (d.specifications?.length)
82
+ out.specifications = d.specifications;
83
+ if (d.inTheBox?.length)
84
+ out.inTheBox = d.inTheBox;
85
+ if (d.defaultCondition)
86
+ out.defaultCondition = d.defaultCondition;
87
+ if (d.defaultAuthenticity)
88
+ out.defaultAuthenticity = d.defaultAuthenticity;
89
+ if (d.seoKeywords?.length)
90
+ out.seoKeywords = d.seoKeywords;
91
+ if (d.faqTemplates?.length)
92
+ out.faqTemplates = d.faqTemplates;
93
+ return out;
94
+ }
95
+ function pickTemplate(doc, variant) {
96
+ const all = doc?.descriptionTemplates;
97
+ if (!all?.length)
98
+ return null;
99
+ /*
100
+ * The asked-for variant, else the category's `default`, else nothing. NOT
101
+ * "the first template in the array": a category offering a reproduction
102
+ * body and a new-in-box body has no natural first, and answering with
103
+ * whichever the author happened to list first would silently hand a
104
+ * brand-new listing the reproduction disclosure.
105
+ */
106
+ return (all.find((t) => t.variant === variant) ??
107
+ all.find((t) => t.variant === "default") ??
108
+ null);
109
+ }
110
+ /**
111
+ * The `productDefaults` fields the picker can inherit, resolved INDIVIDUALLY.
112
+ *
113
+ * 🛑 PER FIELD, not per object, and the first version of this route got it
114
+ * wrong in exactly the way C2a's `applyCategoryDefaults` was written to avoid.
115
+ *
116
+ * A named-model leaf DOES carry a `productDefaults` object — its
117
+ * `defaultFeatures` (blader, battle type, spin) — while the spec rows and the
118
+ * in-the-box list live on the tier-3 line above it. Stopping at "the leaf has
119
+ * an object, so stop looking" therefore returned the blader and silently
120
+ * dropped the specs, so the picker could never say "this will add 2
121
+ * specification rows". Caught by a test asserting the picker still gets what
122
+ * it needs, which the money-stripping assertion sitting beside it would
123
+ * otherwise have made look fine.
124
+ */
125
+ const INHERITABLE_DEFAULT_KEYS = [
126
+ "specifications",
127
+ "inTheBox",
128
+ "defaultCondition",
129
+ "defaultAuthenticity",
130
+ "seoKeywords",
131
+ "faqTemplates",
132
+ ];
133
+ const isEmptyValue = (v) => v == null || (Array.isArray(v) && v.length === 0);
134
+ /** Fill only the keys still missing, from `src`. Returns what is still absent. */
135
+ function mergeDefaults(into, src) {
136
+ if (src) {
137
+ for (const k of INHERITABLE_DEFAULT_KEYS) {
138
+ if (isEmptyValue(into[k]) && !isEmptyValue(src[k])) {
139
+ into[k] = src[k];
140
+ }
141
+ }
142
+ }
143
+ return INHERITABLE_DEFAULT_KEYS.filter((k) => isEmptyValue(into[k]));
144
+ }
145
+ /**
146
+ * Walk the ancestors nearest-first for whatever the leaf did not supply.
147
+ *
148
+ * Extracted because `audit-code-quality` flagged the inline version at
149
+ * brace-depth 6, and it was right — "for each ancestor, if we still need a
150
+ * template, if this one has it" is three questions in one block, and the
151
+ * nesting is what hides the ordering guarantee below.
152
+ */
153
+ async function resolveInherited(parentIds, variant, seed) {
154
+ const out = { ...seed };
155
+ if (parentIds.length === 0)
156
+ return out;
157
+ // `parentIds` is root-first/nearest-last, so reverse it. ONE `getAll`: four
158
+ // sequential reads would be four round trips on a route the seller form hits
159
+ // on every category change.
160
+ const nearestFirst = [...parentIds].reverse();
161
+ const ancestors = await categoriesRepository.findByIds(nearestFirst);
162
+ /*
163
+ * 🛑 Re-indexed into the ASKED-FOR order rather than trusted.
164
+ *
165
+ * `findByIds` dedupes and drops ids that no longer exist, so its result is
166
+ * in request order but may be SHORTER — and "nearest ancestor wins" is the
167
+ * whole semantics here. Walking `nearestFirst` instead of the result means a
168
+ * missing ancestor cannot silently promote a more distant one to first
169
+ * place, which would hand a leaf the ROOT's generic body while reporting
170
+ * that it came from the line.
171
+ */
172
+ const byId = new Map(ancestors.map((a) => [a.id, a]));
173
+ for (const pid of nearestFirst) {
174
+ const d = byId.get(pid);
175
+ if (!d)
176
+ continue;
177
+ const found = out.template ? null : pickTemplate(d, variant);
178
+ if (found) {
179
+ out.template = found;
180
+ out.inheritedFrom = pid;
181
+ out.inheritedFromName = d.name ?? null;
182
+ }
183
+ // PER FIELD — see INHERITABLE_DEFAULT_KEYS. Stop only once the template is
184
+ // settled AND no inheritable default is still missing.
185
+ const stillMissing = mergeDefaults(out.defaults, d.productDefaults);
186
+ if (out.template && stillMissing.length === 0)
187
+ break;
188
+ }
189
+ return out;
190
+ }
191
+ export async function GET(request, context) {
192
+ try {
193
+ const { id } = await context.params;
194
+ const raw = new URL(request.url).searchParams.get("variant") ?? "default";
195
+ const variant = VARIANTS.includes(raw)
196
+ ? raw
197
+ : "default";
198
+ const leafDoc = await categoriesRepository.findById(id);
199
+ if (!leafDoc) {
200
+ return NextResponse.json({ success: false, error: "Category not found" }, { status: 404 });
201
+ }
202
+ const leaf = leafDoc;
203
+ const parentIds = (leaf.parentIds ?? []).slice(0, MAX_ANCESTORS);
204
+ /*
205
+ * The leaf's own defaults are COPIED into the accumulator rather than
206
+ * referenced, so the per-field merge below cannot mutate the document the
207
+ * repository cached.
208
+ */
209
+ const { template, inheritedFrom, inheritedFromName, defaults } = await resolveInherited(parentIds, variant, {
210
+ template: pickTemplate(leaf, variant),
211
+ inheritedFrom: null,
212
+ inheritedFromName: null,
213
+ defaults: { ...(leaf.productDefaults ?? {}) },
214
+ });
215
+ /*
216
+ * 🛑 `placeholders` is RE-DERIVED here and not trusted from the stored
217
+ * template.
218
+ *
219
+ * The field's own contract says absent means "re-parse", never "no slots",
220
+ * and the seeded bodies deliberately leave it unset so there is one
221
+ * implementation rather than a hand-written list that drifts the first time
222
+ * a body is edited. The picker needs the real list to report "2 of 9 slots
223
+ * could not be filled", and a stale list is how a literal `{{series}}`
224
+ * reaches a published description.
225
+ */
226
+ const placeholders = template ? usedPlaceholders(template.body) : [];
227
+ return NextResponse.json({
228
+ success: true,
229
+ data: {
230
+ template: template
231
+ ? { ...template, placeholders }
232
+ : null,
233
+ /** Null when the template came from the category itself. */
234
+ inheritedFrom,
235
+ inheritedFromName,
236
+ defaults: toPublicDefaults(defaults),
237
+ },
238
+ }, {
239
+ headers: {
240
+ // One hour at the edge. The response is a pure function of
241
+ // (id, variant) over admin-authored content; an hour of staleness
242
+ // costs a seller nothing and removes an invocation per form open.
243
+ "Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400",
244
+ },
245
+ });
246
+ }
247
+ catch (err) {
248
+ const e = normalizeError(err);
249
+ serverLogger.error("description-template read failed", { message: e.message });
250
+ return NextResponse.json({ success: false, error: "Could not load the category template" }, { status: 500 });
251
+ }
252
+ }
@@ -17,6 +17,10 @@ import { z } from "zod";
17
17
  import { mediaUrlSchema } from "../../../validation/schemas";
18
18
  import { getProviders } from "../../../contracts";
19
19
  import { createRouteHandler } from "../../../next";
20
+ import { toCategoryListItem } from "../../../_internal/server/features/categories/adapters";
21
+ import { isListingCategory } from "../constants/listing-categories";
22
+ import { planSearchTxt } from "../../../utils/search-txt-query";
23
+ import { matchesAllSearchTerms } from "../../../utils/search-txt";
20
24
  import { normalizeError } from "../../../errors/normalize";
21
25
  const CACHE_CONTROL_PUBLIC = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
22
26
  function buildTreeFromFlat(items, rootId) {
@@ -77,10 +81,65 @@ export async function GET(request) {
77
81
  const rawFilters = param(url, "filters");
78
82
  const tier = numParam(url, "tier");
79
83
  const pageSizeParam = numParam(url, "pageSize");
84
+ /*
85
+ * 🛑 `?page=` MEANS "give me a paged flat list", and reading it fixes two
86
+ * live bugs rather than adding a feature.
87
+ *
88
+ * (a) `useAdminListingData` ALWAYS sends `page` and `pageSize`, so
89
+ * `/admin/categories` has been requesting `?flat=true&page=N` while
90
+ * this handler ignored `page` entirely — the pager advanced the URL
91
+ * and returned page 1's rows every time. It also returned a bare
92
+ * array with no `total`, so `extractCategoryTotal` fell back to
93
+ * `mappedRows.length`, making `totalPages` 1 and disabling Next. The
94
+ * list showed 50 of 58 categories with no way to reach the rest.
95
+ *
96
+ * (b) `loadOptionsFrom` (the catalogue editor's category and brand
97
+ * pickers) sends ONLY `page`/`pageSize`/`q` — none of which `isFiltered`
98
+ * tested — so it fell through to TREE mode and then read `.items` off
99
+ * a bare array of nested nodes. `data.items ?? []` meant both pickers
100
+ * were permanently empty. Same shape as the `useCategoryTree` bug:
101
+ * `isFiltered` did not include the params real callers send.
102
+ */
103
+ const pageParam = numParam(url, "page");
104
+ const isPaged = pageParam !== null && pageParam > 0;
105
+ /*
106
+ * `?listingOnly=true` drops brand / bundle / sublisting rows.
107
+ *
108
+ * Applied in memory because the test cannot be a query clause — a plain
109
+ * listing category OMITS `categoryType`, so there is no value to match
110
+ * and an inequality would exclude every real category. See
111
+ * `constants/listing-categories.ts`.
112
+ *
113
+ * Server-side rather than per-caller: three surfaces had hand-written the
114
+ * same Set and the fourth (`loadCategoryOptions`, the catalogue editor's
115
+ * category picker) shipped without one, offering "Takara-Tomy" and
116
+ * "Original Collector's Set" as categories.
117
+ */
118
+ const listingOnly = param(url, "listingOnly") === "true";
119
+ /*
120
+ * `q` is read now that `searchTxt` exists.
121
+ *
122
+ * `loadOptionsFrom` has always sent it and this handler never read it, so
123
+ * the catalogue editor's category picker returned the same unfiltered page
124
+ * for every term — the search box accepted typing and changed nothing.
125
+ * Root Cause #62's shape, and the second half of why that picker was
126
+ * useless (the first being that it got a nested tree, §A1c).
127
+ */
128
+ const q = (param(url, "q") ?? "").trim();
80
129
  const { db } = getProviders();
81
130
  if (!db) {
82
131
  return NextResponse.json({ success: false, error: "Database provider not registered" }, { status: 503 });
83
132
  }
133
+ /*
134
+ * 🛑 Typed as the DOCUMENT, not as `CategoryItem`.
135
+ *
136
+ * This was `getRepository<CategoryItem>`, which is a cast-by-generic: the
137
+ * repository returns raw Firestore documents at runtime, so every return
138
+ * site below was handing out `createdBy`, `createdByStoreId` and the
139
+ * unbounded `metrics.productIds[]`/`auctionIds[]` while tsc believed the
140
+ * payload was already projected. Naming the real shape is what makes the
141
+ * missing `toCategoryListItem` calls a compile error instead of a leak.
142
+ */
84
143
  const repo = db.getRepository("categories");
85
144
  // -- Single slug lookup ----------------------------------------------------
86
145
  if (slug) {
@@ -92,7 +151,7 @@ export async function GET(request) {
92
151
  if (!category) {
93
152
  return NextResponse.json({ success: false, error: "Category not found" }, { status: 404 });
94
153
  }
95
- return NextResponse.json({ success: true, data: category });
154
+ return NextResponse.json({ success: true, data: toCategoryListItem(category) });
96
155
  }
97
156
  // -- Build Sieve filter string from query params ----------------------------
98
157
  const parts = [];
@@ -117,6 +176,7 @@ export async function GET(request) {
117
176
  // -- Filtered flat list modes -----------------------------------------------
118
177
  // If any filter is active, or ?flat=true → return flat array
119
178
  const isFiltered = flat === "true" ||
179
+ isPaged ||
120
180
  parentId !== null ||
121
181
  featured === "true" ||
122
182
  isBrand === "true" ||
@@ -125,14 +185,95 @@ export async function GET(request) {
125
185
  type !== null;
126
186
  if (isFiltered) {
127
187
  const perPage = pageSizeParam !== null && pageSizeParam > 0 ? pageSizeParam : 200;
128
- const result = await repo.findAll({
129
- filters,
130
- sort: "order",
131
- order: "asc",
132
- perPage,
133
- });
134
- const items = result.data.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
135
- const res = NextResponse.json({ success: true, data: items });
188
+ const page = pageParam ?? 1;
189
+ /*
190
+ * 🛑 `listingOnly` and `q` both force an in-memory window, and `total`
191
+ * is the reason.
192
+ *
193
+ * Each removes rows AFTER the query, so the repository's own `total`
194
+ * would be an over-count and `hasMore` would promise a "Load more" page
195
+ * that renders empty. Rather than ship a number that is wrong in a
196
+ * direction nobody can see, fetch one bounded window, filter it, and
197
+ * paginate the result — so `total` is exact.
198
+ *
199
+ * Affordable because this collection is small and hard-capped at 500 by
200
+ * `base.ts` anyway: 58 rows today, ~330 after the taxonomy seed. Past
201
+ * the cap the window truncates, which is the same ceiling the sitemap
202
+ * builder has and is tracked as its own item — it is not made worse
203
+ * here.
204
+ *
205
+ * The MATCH uses `matchesAllSearchTerms` over the stored `searchTxt`,
206
+ * not `name.includes()`, so this route and the admin route's real
207
+ * push-down agree on what a term means: word prefixes, accent-folded,
208
+ * all terms ANDed, lineage included via `ancestors[].name`. Two
209
+ * different notions of "matches" across two pickers over one collection
210
+ * is the drift this reuse avoids.
211
+ */
212
+ const plan = planSearchTxt(q);
213
+ // A query that normalised to no usable token must return NOTHING.
214
+ // Emitting no clause returns the whole collection and calls it a result.
215
+ if (plan.empty) {
216
+ const res = isPaged
217
+ ? NextResponse.json({
218
+ success: true,
219
+ data: { items: [], total: 0, page, perPage, totalPages: 0, hasMore: false },
220
+ })
221
+ : NextResponse.json({ success: true, data: [] });
222
+ res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
223
+ return res;
224
+ }
225
+ const terms = plan.head ? [plan.head, ...plan.rest] : [];
226
+ const needsWindow = listingOnly || terms.length > 0;
227
+ const windowed = needsWindow
228
+ ? await repo.findAll({ filters, sort: "order", order: "asc", perPage: 500 })
229
+ : null;
230
+ let items;
231
+ let total;
232
+ if (windowed) {
233
+ const kept = windowed.data
234
+ .filter((d) => (listingOnly ? isListingCategory(d) : true))
235
+ .filter((d) => (terms.length ? matchesAllSearchTerms(d.searchTxt, terms) : true))
236
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
237
+ total = kept.length;
238
+ const offset = (page - 1) * perPage;
239
+ items = (isPaged ? kept.slice(offset, offset + perPage) : kept).map(toCategoryListItem);
240
+ }
241
+ else {
242
+ const result = await repo.findAll({
243
+ filters,
244
+ sort: "order",
245
+ order: "asc",
246
+ page: pageParam ?? undefined,
247
+ perPage,
248
+ });
249
+ total = result.total;
250
+ items = result.data
251
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
252
+ .map(toCategoryListItem);
253
+ }
254
+ const totalPages = total === 0 ? 0 : Math.max(1, Math.ceil(total / perPage));
255
+ /*
256
+ * Shape switches on `?page=`, deliberately, and only there.
257
+ *
258
+ * A paged caller gets an envelope carrying `total`/`totalPages`/`hasMore`
259
+ * — without `total` the admin pager computes `totalPages = 1` from the
260
+ * row count and disables Next, which is the "50 of 58 rows" bug. A
261
+ * caller that sends no `page` keeps the bare array it has always had,
262
+ * because `useCategories` and `useTopCategories` are TYPED as
263
+ * `CategoryItem[]` and would throw on `.map` of an object (Root Cause
264
+ * #20 — a public shape change must update its call sites, and these
265
+ * callers never ask for paging, so the correct move is not to change
266
+ * their shape at all).
267
+ *
268
+ * `hasMore` exists because `loadOptionsFrom` reads it for "Load more";
269
+ * it used to read it off a bare array and always got `false`.
270
+ */
271
+ const res = isPaged
272
+ ? NextResponse.json({
273
+ success: true,
274
+ data: { items, total, page, perPage, totalPages, hasMore: page < totalPages },
275
+ })
276
+ : NextResponse.json({ success: true, data: items });
136
277
  res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
137
278
  return res;
138
279
  }
@@ -143,8 +284,12 @@ export async function GET(request) {
143
284
  order: "asc",
144
285
  perPage: 500,
145
286
  });
287
+ // Project BEFORE nesting — `CategoryTreeNode extends CategoryItem`, so the
288
+ // children carry the public shape too rather than being raw documents
289
+ // reachable one level down.
290
+ const allItems = allResult.data.map(toCategoryListItem);
146
291
  if (tree === "true" || !flat) {
147
- const treeNodes = buildTreeFromFlat(allResult.data, rootId);
292
+ const treeNodes = buildTreeFromFlat(allItems, rootId);
148
293
  const res = NextResponse.json({ success: true, data: treeNodes });
149
294
  res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
150
295
  return res;
@@ -153,7 +298,7 @@ export async function GET(request) {
153
298
  // because isFiltered catches it, but kept for clarity)
154
299
  const res = NextResponse.json({
155
300
  success: true,
156
- data: allResult.data.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
301
+ data: allItems.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
157
302
  });
158
303
  res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
159
304
  return res;
@@ -1,8 +1,6 @@
1
1
  import React from "react";
2
- import type { CategoryItem } from "../types";
3
2
  export interface CategoriesIndexListingProps {
4
- initialData?: CategoryItem[];
5
3
  /** When true, filters to brands only (for the standalone /brands page) */
6
4
  brandsOnly?: boolean;
7
5
  }
8
- export declare function CategoriesIndexListing({ initialData: _, brandsOnly }: CategoriesIndexListingProps): React.JSX.Element;
6
+ export declare function CategoriesIndexListing({ brandsOnly }: CategoriesIndexListingProps): React.JSX.Element;
@@ -63,7 +63,7 @@ const SORT_OPTIONS = [
63
63
  { value: sortBy(CATEGORY_FIELDS.NAME), label: "Name Z–A" },
64
64
  { value: sortBy("metrics.productCount", "DESC"), label: "Most Products" },
65
65
  ];
66
- export function CategoriesIndexListing({ initialData: _, brandsOnly = false }) {
66
+ export function CategoriesIndexListing({ brandsOnly = false }) {
67
67
  const table = useUrlTable({ defaults: { pageSize: String(PAGE_SIZE), sort: DEFAULT_SORT } });
68
68
  const [searchInput, setSearchInput] = useState(table.get(TABLE_KEYS.QUERY) || "");
69
69
  const [filterOpen, setFilterOpen] = useState(false);
@@ -1,7 +1,33 @@
1
1
  import React from "react";
2
2
  type SearchParams = Record<string, string | string[]>;
3
3
  export interface CategoriesIndexPageViewProps {
4
+ /**
5
+ * @deprecated Accepted and ignored. Kept so the consumer page shim compiles
6
+ * either way; remove once no caller passes it.
7
+ */
4
8
  searchParams?: SearchParams;
5
9
  }
6
- export declare function CategoriesIndexPageView({ searchParams }: CategoriesIndexPageViewProps): Promise<React.JSX.Element>;
10
+ /**
11
+ * The categories index.
12
+ *
13
+ * 🛑 This view deliberately performs NO Firestore read.
14
+ *
15
+ * It used to fetch `pageSize: 200` and hand the result to
16
+ * `CategoriesIndexListing` as `initialData` — which destructured it as
17
+ * `initialData: _` and threw it away, then fetched `/api/categories?flat=true`
18
+ * regardless. So the read was 200 documents per render, on a route that
19
+ * regenerated every 300s, rendering nothing. Measured 2026-10-09 while tracing
20
+ * 2.1M Firestore reads/week against a 50K/day free tier.
21
+ *
22
+ * Deleting it also removes this page from Root Cause #30's blast radius
23
+ * entirely: you cannot freeze the wrong data under a `staleTime: Infinity` key
24
+ * you never seeded. The long "MUST MATCH CategoriesIndexListing's DEFAULT_SORT"
25
+ * warning that used to live here is now moot for the same reason — there is no
26
+ * second parser to keep in sync.
27
+ *
28
+ * Unlike the other listing pages, making this one static costs no SEO: its grid
29
+ * was already client-rendered, so there was never a server-rendered grid to
30
+ * lose (contrast `/products`, where the grid IS in the SSR HTML and must stay).
31
+ */
32
+ export declare function CategoriesIndexPageView(_props?: CategoriesIndexPageViewProps): Promise<React.JSX.Element>;
7
33
  export {};