@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
@@ -1,13 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
3
- import { sortBy } from "../../../constants/sort";
4
- import { CATEGORY_FIELDS } from "../../../constants/field-names";
5
- import { categoriesRepository } from "../../../repositories";
6
3
  import { Container, Heading, Main, Section } from "../../../ui";
7
4
  import { AdSlot } from "../../homepage/components/AdSlot";
8
5
  import { CategoriesIndexListing } from "./CategoriesIndexListing";
9
- import { safeRead } from "../../../errors/safe-read";
10
- import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
11
6
  function sp(params, key) {
12
7
  const v = params[key];
13
8
  return Array.isArray(v) ? v[0] ?? "" : v ?? "";
@@ -39,32 +34,28 @@ function buildCategoryFilters(params) {
39
34
  }
40
35
  return parts.join(",");
41
36
  }
42
- export async function CategoriesIndexPageView({ searchParams = {} }) {
43
- /*
44
- * 🛑 MUST MATCH CategoriesIndexListing's DEFAULT_SORT.
45
- *
46
- * This defaulted to "name" while the client listing defaulted to the same,
47
- * and both were wrong: the roots fell off page 1. Now both default to tier
48
- * ascending — and they have to agree, because this view hands its result to
49
- * the listing as initialData and every public listing hook sets
50
- * `staleTime: Infinity` when given it. A disagreement here is not a
51
- * transient wrong first paint; it is frozen for that query key
52
- * (Root Cause #30).
53
- */
54
- const sort = sp(searchParams, "sort") || sortBy(CATEGORY_FIELDS.TIER, "ASC");
55
- const page = Number(sp(searchParams, "page")) || 1;
56
- const filters = buildCategoryFilters(searchParams);
57
- const result = await safeRead(() => categoriesRepository.list({
58
- page,
59
- pageSize: 200,
60
- sorts: sort,
61
- ...(filters ? { filters } : {}),
62
- }), { route: "/categories", key: "categories.list", fallback: null });
63
- /*
64
- * This one page carries THREE entity kinds — categories, brands and bundles
65
- * are all `CategoryDocument` rows discriminated by `categoryType` — so a
66
- * single unfiltered read published sandbox fixtures of all three.
67
- */
68
- const initialData = hidePublicTestData((result?.items ?? []));
69
- return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Categories" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(CategoriesIndexListing, { initialData: initialData }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
37
+ /**
38
+ * The categories index.
39
+ *
40
+ * 🛑 This view deliberately performs NO Firestore read.
41
+ *
42
+ * It used to fetch `pageSize: 200` and hand the result to
43
+ * `CategoriesIndexListing` as `initialData` — which destructured it as
44
+ * `initialData: _` and threw it away, then fetched `/api/categories?flat=true`
45
+ * regardless. So the read was 200 documents per render, on a route that
46
+ * regenerated every 300s, rendering nothing. Measured 2026-10-09 while tracing
47
+ * 2.1M Firestore reads/week against a 50K/day free tier.
48
+ *
49
+ * Deleting it also removes this page from Root Cause #30's blast radius
50
+ * entirely: you cannot freeze the wrong data under a `staleTime: Infinity` key
51
+ * you never seeded. The long "MUST MATCH CategoriesIndexListing's DEFAULT_SORT"
52
+ * warning that used to live here is now moot for the same reason — there is no
53
+ * second parser to keep in sync.
54
+ *
55
+ * Unlike the other listing pages, making this one static costs no SEO: its grid
56
+ * was already client-rendered, so there was never a server-rendered grid to
57
+ * lose (contrast `/products`, where the grid IS in the SSR HTML and must stay).
58
+ */
59
+ export async function CategoriesIndexPageView(_props = {}) {
60
+ return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Categories" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(CategoriesIndexListing, {}), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
70
61
  }
@@ -21,6 +21,16 @@ const __O = {
21
21
  hidden: "overflow-hidden",
22
22
  xAuto: "overflow-x-auto",
23
23
  };
24
+ /**
25
+ * Read and display bounds for this page. Every one of these replaced an
26
+ * unbounded read — see the comments at each use site for what each one cost.
27
+ */
28
+ /** Tiles rendered in "Related Categories". A sample, not an index. */
29
+ const RELATED_CATEGORY_LIMIT = 24;
30
+ /** Documents read to fill those tiles. Headroom for the non-listing filter. */
31
+ const RELATED_CATEGORY_FETCH = 60;
32
+ /** Descendant slugs the Stores tab will match on → at most 4 chunked queries. */
33
+ const STORE_CATEGORY_SLUG_MAX = 120;
24
34
  export async function CategoryDetailPageView({ slug }) {
25
35
  // The category IS this page's subject — a read failure must surface rather
26
36
  // than render an anonymous, empty category page. `undefined` still means
@@ -75,8 +85,18 @@ export async function CategoryDetailPageView({ slug }) {
75
85
  // Related categories — every other category sharing this category's root
76
86
  // (siblings + cousins across the tree, up to the tier-0/1/2 depth the
77
87
  // catalog actually uses), not just direct children.
88
+ //
89
+ // 🛑 BOUNDED. This read is a display grid, and it used to be an unbounded
90
+ // `.get()` over the whole root subtree — ~9% of a 50K/day Firestore budget
91
+ // for one page render once the forest reaches ~330 nodes, and it rendered
92
+ // every one of those as a tile. The section is a SAMPLE of nearby
93
+ // categories; the tier-grouped children grid above it is the real index.
94
+ // Fetch headroom is larger than the display cap because the filter below
95
+ // drops this category plus every brand/bundle/sublisting row.
96
+ // `buildTree` still passes no limit — a truncated node list there would
97
+ // silently drop whole branches.
78
98
  category?.rootId
79
- ? safeRead(() => categoriesRepository.getCategoriesByRootId(category.rootId), {
99
+ ? safeRead(() => categoriesRepository.getCategoriesByRootId(category.rootId, { limit: RELATED_CATEGORY_FETCH }), {
80
100
  route: "/categories/[slug]",
81
101
  key: "category.rootSiblings",
82
102
  fallback: [],
@@ -98,7 +118,9 @@ export async function CategoryDetailPageView({ slug }) {
98
118
  fallback: null,
99
119
  }),
100
120
  ]);
101
- const relatedCategories = rootSiblingCategories.filter((c) => c.id !== category?.id && (!c.categoryType || c.categoryType === "category"));
121
+ const relatedCategories = rootSiblingCategories
122
+ .filter((c) => c.id !== category?.id && (!c.categoryType || c.categoryType === "category"))
123
+ .slice(0, RELATED_CATEGORY_LIMIT);
102
124
  // Stores tab — stores whose storeCategory is this category or ANY descendant.
103
125
  //
104
126
  // `storeCategory` is a single slug with no ancestor chain (unlike a product's
@@ -108,14 +130,25 @@ export async function CategoryDetailPageView({ slug }) {
108
130
  // an unbounded N+1. Both are fixed by pipe-joining the slugs into OR-groups:
109
131
  // the enhanced Sieve adapter turns a same-field OR into a Firestore `in`,
110
132
  // which caps at 30 values, hence the chunking.
111
- const storeCategorySlugs = [
112
- slug,
113
- ...(await safeRead(() => categoriesRepository.getDescendantIds(category?.id ?? ""), {
114
- route: "/categories/[slug]",
115
- key: "category.descendantIds",
116
- fallback: [],
117
- })),
118
- ].filter(Boolean);
133
+ //
134
+ // 🛑 The descendant ids are REUSED from `childCategories` above, not fetched
135
+ // again. `getDescendantIds` is literally `getDescendants().map(d => d.id)`,
136
+ // so calling it here ran the identical paginated subtree query a second time
137
+ // and threw the documents away — on a ~330-node root that is ~330 wasted
138
+ // reads per page view, and the two answers could disagree if one read failed
139
+ // while the other succeeded.
140
+ //
141
+ // 🛑 And the fan-out is CAPPED, nearest-first. One `listStores` query per 30
142
+ // slugs is 11 queries at ~330 nodes and 51 at ~1,500; sorting by tier takes
143
+ // the closest descendants, which is what someone browsing a root cares
144
+ // about. `storeCount` was already a floor (each chunk caps at pageSize 50),
145
+ // so this tightens a floor rather than making an exact number wrong.
146
+ const descendantIdsByProximity = [...childCategories]
147
+ .sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0))
148
+ .map((c) => c.id);
149
+ const storeCategorySlugs = [slug, ...descendantIdsByProximity]
150
+ .filter(Boolean)
151
+ .slice(0, STORE_CATEGORY_SLUG_MAX);
119
152
  const SLUG_CHUNK = 30;
120
153
  const slugChunks = [];
121
154
  for (let i = 0; i < storeCategorySlugs.length; i += SLUG_CHUNK) {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The seller form's category description-template picker — C2c.
3
+ *
4
+ * Fills `SellerProductShell`'s `renderTemplateSelector` slot, which existed
5
+ * with zero consumers until C2b widened it (Root Cause #8's shape: a slot
6
+ * shell rendered with no render props shows a layout skeleton and no content).
7
+ *
8
+ * ## What it is for
9
+ *
10
+ * Our sellers write every description from scratch because no
11
+ * category→description mechanism has ever existed. The competitor crawl shows
12
+ * the model: worldhobbyshop reuses TWO bodies across 1,195 listings — a
13
+ * pre-owned disclosure and a new-in-box spec block — and tcgindia generates
14
+ * five templated FAQs per product. The bodies live on the CATEGORY, so fixing
15
+ * the copy fixes every future listing at once.
16
+ *
17
+ * ## 🛑 The clobber rule is the whole design
18
+ *
19
+ * `isDescriptionDirty` arrives from the shell: non-empty after trim AND not
20
+ * byte-identical to a body applied this session. When it is false the picker
21
+ * may fill silently — the seller is looking at either an empty box or our own
22
+ * text. When it is TRUE it must not overwrite; it offers **Replace
23
+ * description** instead, which is destructive and therefore carries a
24
+ * `confirmation` on its ActionDef (Rule #7), not a `window.confirm`.
25
+ *
26
+ * ## 🛑 "No template" must never blank the field
27
+ *
28
+ * A category with no body at any ancestor renders "No template yet" and leaves
29
+ * whatever is in the box alone. Blanking a field to signal absence is how a
30
+ * seller loses work to a feature that was trying to help.
31
+ */
32
+ import React from "react";
33
+ import type { CategoryTemplateVariant } from "../schemas/category-content";
34
+ /** What the picker may apply to the draft. Deliberately narrow. */
35
+ export interface CategoryTemplateApply {
36
+ description?: string;
37
+ appliedTemplateId?: string;
38
+ appliedTemplateBody?: string;
39
+ }
40
+ export interface CategoryTemplatePickerProps {
41
+ /** The leaf the seller has chosen. Empty until they pick one. */
42
+ categoryId: string;
43
+ /** The raw `condition` enum from the draft — mapped to a variant below. */
44
+ condition: string;
45
+ /** Resolved `{{slot}}` values, supplied by the shell. */
46
+ context: Record<string, string>;
47
+ /** True when the seller has authored text we must not overwrite. */
48
+ isDescriptionDirty: boolean;
49
+ onApply: (partial: CategoryTemplateApply) => void;
50
+ }
51
+ /**
52
+ * Which body a condition wants.
53
+ *
54
+ * 🛑 The variants are the conditions that change what the copy must SAY, not
55
+ * every `CONDITION_OPTIONS` value — a "pre-owned" and a "used" listing want
56
+ * the same disclosure paragraph, which is why `CategoryTemplateVariant` has
57
+ * five members against the form's six conditions.
58
+ *
59
+ * `refurbished` maps to `pre_owned` rather than `default`: it has been owned
60
+ * and worked on, so the disclosure applies. Mapping it to `default` would hand
61
+ * a refurbished item a brand-new-item body, which is the one wrong answer
62
+ * available here.
63
+ */
64
+ export declare function variantForCondition(condition: string): CategoryTemplateVariant;
65
+ export declare function CategoryTemplatePicker({ categoryId, condition, context, isDescriptionDirty, onApply, }: CategoryTemplatePickerProps): React.JSX.Element | null;
@@ -0,0 +1,170 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * The seller form's category description-template picker — C2c.
5
+ *
6
+ * Fills `SellerProductShell`'s `renderTemplateSelector` slot, which existed
7
+ * with zero consumers until C2b widened it (Root Cause #8's shape: a slot
8
+ * shell rendered with no render props shows a layout skeleton and no content).
9
+ *
10
+ * ## What it is for
11
+ *
12
+ * Our sellers write every description from scratch because no
13
+ * category→description mechanism has ever existed. The competitor crawl shows
14
+ * the model: worldhobbyshop reuses TWO bodies across 1,195 listings — a
15
+ * pre-owned disclosure and a new-in-box spec block — and tcgindia generates
16
+ * five templated FAQs per product. The bodies live on the CATEGORY, so fixing
17
+ * the copy fixes every future listing at once.
18
+ *
19
+ * ## 🛑 The clobber rule is the whole design
20
+ *
21
+ * `isDescriptionDirty` arrives from the shell: non-empty after trim AND not
22
+ * byte-identical to a body applied this session. When it is false the picker
23
+ * may fill silently — the seller is looking at either an empty box or our own
24
+ * text. When it is TRUE it must not overwrite; it offers **Replace
25
+ * description** instead, which is destructive and therefore carries a
26
+ * `confirmation` on its ActionDef (Rule #7), not a `window.confirm`.
27
+ *
28
+ * ## 🛑 "No template" must never blank the field
29
+ *
30
+ * A category with no body at any ancestor renders "No template yet" and leaves
31
+ * whatever is in the box alone. Blanking a field to signal absence is how a
32
+ * seller loses work to a feature that was trying to help.
33
+ */
34
+ import React from "react";
35
+ import { useQuery } from "@tanstack/react-query";
36
+ import { Button, Div, Row, Stack, Text } from "../../../ui";
37
+ import { apiClient } from "../../../http";
38
+ import { CATEGORY_ENDPOINTS } from "../../../constants/api-endpoints";
39
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
40
+ import { interpolate } from "../../../_internal/shared/templating/placeholders";
41
+ const PANEL_CLASS = "rounded-lg border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-3)] py-[var(--appkit-space-3)]";
42
+ /**
43
+ * Which body a condition wants.
44
+ *
45
+ * 🛑 The variants are the conditions that change what the copy must SAY, not
46
+ * every `CONDITION_OPTIONS` value — a "pre-owned" and a "used" listing want
47
+ * the same disclosure paragraph, which is why `CategoryTemplateVariant` has
48
+ * five members against the form's six conditions.
49
+ *
50
+ * `refurbished` maps to `pre_owned` rather than `default`: it has been owned
51
+ * and worked on, so the disclosure applies. Mapping it to `default` would hand
52
+ * a refurbished item a brand-new-item body, which is the one wrong answer
53
+ * available here.
54
+ */
55
+ export function variantForCondition(condition) {
56
+ switch (condition) {
57
+ case "new":
58
+ return "new_in_box";
59
+ case "like_new":
60
+ case "good":
61
+ case "fair":
62
+ case "used":
63
+ case "refurbished":
64
+ return "pre_owned";
65
+ default:
66
+ return "default";
67
+ }
68
+ }
69
+ function unwrap(res) {
70
+ const body = res?.data ?? res;
71
+ return body && typeof body === "object" && "template" in body ? body : null;
72
+ }
73
+ /** "2 of 9 slots could not be filled: {{year}}, {{type}}" */
74
+ function unresolvedNote(unresolved, total) {
75
+ if (unresolved.length === 0)
76
+ return null;
77
+ const named = unresolved.map((n) => `{{${n}}}`).join(", ");
78
+ return `${unresolved.length} of ${total} slots could not be filled: ${named}`;
79
+ }
80
+ /** "adds 2 specification rows · sets condition to Used" */
81
+ function describeDefaults(defaults) {
82
+ const parts = [];
83
+ const specs = defaults.specifications?.length ?? 0;
84
+ if (specs > 0)
85
+ parts.push(`${specs} specification row${specs === 1 ? "" : "s"}`);
86
+ const box = defaults.inTheBox?.length ?? 0;
87
+ if (box > 0)
88
+ parts.push(`a "what's in the box" list of ${box}`);
89
+ const kw = defaults.seoKeywords?.length ?? 0;
90
+ if (kw > 0)
91
+ parts.push(`${kw} SEO keyword${kw === 1 ? "" : "s"}`);
92
+ const faqs = defaults.faqTemplates?.length ?? 0;
93
+ if (faqs > 0)
94
+ parts.push(`${faqs} listing FAQ${faqs === 1 ? "" : "s"}`);
95
+ return parts;
96
+ }
97
+ export function CategoryTemplatePicker({ categoryId, condition, context, isDescriptionDirty, onApply, }) {
98
+ const variant = variantForCondition(condition);
99
+ /*
100
+ * Keyed on (category, variant) — the same pair the endpoint's edge cache is
101
+ * keyed on, so switching back to a condition the seller already looked at
102
+ * costs nothing at all. Fired on change, never per keystroke: `condition` is
103
+ * a select and `categoryId` comes from a picker.
104
+ */
105
+ const query = useQuery({
106
+ queryKey: ["category-description-template", categoryId, variant],
107
+ enabled: categoryId.length > 0,
108
+ staleTime: 60 * 60 * 1000,
109
+ queryFn: async () => unwrap(await apiClient.get(CATEGORY_ENDPOINTS.DESCRIPTION_TEMPLATE(categoryId, variant))),
110
+ });
111
+ const data = query.data ?? null;
112
+ const template = data?.template ?? null;
113
+ /*
114
+ * Interpolated here rather than on apply, so the unresolved-slot note is
115
+ * accurate BEFORE the seller commits. A note that appears only after the
116
+ * text lands is a report on something they can no longer choose about.
117
+ */
118
+ const filled = React.useMemo(() => (template ? interpolate(template.body, context) : null), [template, context]);
119
+ /*
120
+ * Auto-fill when the seller owns nothing. Deliberately an effect rather than
121
+ * a render-time write: the body has to reach the draft, which is the
122
+ * shell's state.
123
+ *
124
+ * 🛑 Guarded on `isDescriptionDirty` reading FALSE, which covers all three
125
+ * silent cases the plan specifies — never touched, cleared, or still showing
126
+ * a body we applied. The seller who typed one character gets the button
127
+ * instead.
128
+ *
129
+ * `filled.text` is in the dependency list, so changing the category or the
130
+ * condition refreshes a body we own. That is the "Updated for Burst Dual
131
+ * Layer" case, and it is the reason this is not a one-shot.
132
+ */
133
+ const applied = data && template && filled;
134
+ React.useEffect(() => {
135
+ if (!applied || isDescriptionDirty)
136
+ return;
137
+ onApply({
138
+ description: filled.text,
139
+ appliedTemplateId: template.id,
140
+ appliedTemplateBody: filled.text,
141
+ });
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, [applied, isDescriptionDirty, filled?.text, template?.id]);
144
+ if (categoryId.length === 0)
145
+ return null;
146
+ if (query.isLoading) {
147
+ return (_jsx(Div, { className: PANEL_CLASS, children: _jsx(Text, { size: "xs", color: "muted", children: "Looking for a description template for this category\u2026" }) }));
148
+ }
149
+ /*
150
+ * 🛑 A failed fetch says so, and does NOT fall through to "No template yet".
151
+ * Those are different facts, and conflating them is Root Cause #72's rule
152
+ * one level out: absence must never be reported as a measured zero.
153
+ */
154
+ if (query.isError) {
155
+ return (_jsx(Div, { className: PANEL_CLASS, children: _jsx(Text, { size: "xs", color: "muted", children: "Could not load the category's description template. Your description is untouched \u2014 write your own, or try again in a moment." }) }));
156
+ }
157
+ if (!data || !template || !filled) {
158
+ return (_jsx(Div, { className: PANEL_CLASS, children: _jsx(Text, { size: "xs", color: "muted", children: "No description template for this category yet \u2014 write your own, and an admin can add one so the next listing starts from it." }) }));
159
+ }
160
+ const note = unresolvedNote(filled.unresolved, template.placeholders?.length ?? filled.unresolved.length);
161
+ const extras = describeDefaults(data.defaults);
162
+ const source = data.inheritedFromName
163
+ ? `Inherited from ${data.inheritedFromName}`
164
+ : "From this category";
165
+ return (_jsx(Div, { className: PANEL_CLASS, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { gap: "sm", align: "center", justify: "between", wrap: true, children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: template.label }), _jsxs(Text, { size: "xs", color: "muted", children: [source, isDescriptionDirty ? "" : " · applied"] })] }), isDescriptionDirty ? (_jsx(Button, { action: ACTIONS.STORE["replace-description"], size: "sm", onClick: () => onApply({
166
+ description: filled.text,
167
+ appliedTemplateId: template.id,
168
+ appliedTemplateBody: filled.text,
169
+ }) })) : null] }), note ? (_jsx(Text, { size: "xs", color: "muted", children: note })) : null, extras.length > 0 ? (_jsxs(Text, { size: "xs", color: "muted", children: ["This category also supplies ", extras.join(", "), " when you save."] })) : null, isDescriptionDirty ? (_jsx(Text, { size: "xs", color: "muted", children: "Your own description is kept \u2014 nothing was overwritten." })) : null] }) }));
170
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `categoryType` values that are NOT browsable listing categories.
3
+ *
4
+ * - `brand` — a manufacturer/publisher row, browsed at `/brands/{slug}`
5
+ * - `bundle` — a priced bundle, browsed at `/bundles/{slug}`
6
+ * - `sublisting`— a grading/variant axis (`"PSA 10"`, `"105RF"`), never browsed
7
+ */
8
+ export declare const NON_LISTING_CATEGORY_TYPES: ReadonlySet<string>;
9
+ /**
10
+ * True when a category row is a real, browsable listing category.
11
+ *
12
+ * Structural parameter on purpose: callers hold `CategoryDocument` (server) or
13
+ * `CategoryItem` (client) and neither should have to be imported here.
14
+ */
15
+ export declare function isListingCategory(cat: {
16
+ categoryType?: string | null;
17
+ }): boolean;
@@ -0,0 +1,53 @@
1
+ /*
2
+ * WHY: four surfaces need to answer "is this row a browsable listing category,
3
+ * or is it a brand / bundle / sublisting sharing the collection?" — the
4
+ * sitemap, the filter-facet hook, the public categories route, and the
5
+ * catalogue editor's picker. Three of them had already hand-written the
6
+ * same Set, which is how the fourth got shipped without one.
7
+ *
8
+ * WHAT: one Set and one predicate. No imports, so it is safe in a client
9
+ * bundle (Root Cause #6) and safe in a server-only metadata path
10
+ * (Root Cause #76).
11
+ *
12
+ * 🛑 THE TEST CANNOT BE A FIRESTORE CLAUSE, and that is the whole reason this
13
+ * exists as a predicate rather than a query filter. A plain listing category
14
+ * **OMITS** `categoryType` entirely — that is the convention every seeded
15
+ * listing category follows — so there is no value to match on and
16
+ * `where("categoryType", "not-in", [...])` would exclude every real category
17
+ * (a Firestore inequality drops documents that lack the field). It has to be
18
+ * applied in memory, after the fetch.
19
+ *
20
+ * Measured cost of not doing this: the `/products` category facet offered 11
21
+ * options of which **9 were not categories** — "Takara-Tomy", "Hasbro",
22
+ * "Burst Battlers Pack", "X-Series Starter Set" — and the catalogue editor's
23
+ * category picker offered "Original Collector's Set" and "Metal Fusion Duo".
24
+ * It fails silently because a short list of plausible names is
25
+ * indistinguishable from a correct one.
26
+ *
27
+ * @tag domain:categories
28
+ * @tag layer:constants
29
+ * @tag access:isomorphic
30
+ * @tag sideEffects:none
31
+ */
32
+ /**
33
+ * `categoryType` values that are NOT browsable listing categories.
34
+ *
35
+ * - `brand` — a manufacturer/publisher row, browsed at `/brands/{slug}`
36
+ * - `bundle` — a priced bundle, browsed at `/bundles/{slug}`
37
+ * - `sublisting`— a grading/variant axis (`"PSA 10"`, `"105RF"`), never browsed
38
+ */
39
+ export const NON_LISTING_CATEGORY_TYPES = new Set([
40
+ "brand",
41
+ "bundle",
42
+ "sublisting",
43
+ ]);
44
+ /**
45
+ * True when a category row is a real, browsable listing category.
46
+ *
47
+ * Structural parameter on purpose: callers hold `CategoryDocument` (server) or
48
+ * `CategoryItem` (client) and neither should have to be imported here.
49
+ */
50
+ export function isListingCategory(cat) {
51
+ const kind = cat.categoryType;
52
+ return typeof kind !== "string" || !NON_LISTING_CATEGORY_TYPES.has(kind);
53
+ }
@@ -2,6 +2,7 @@
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { apiClient } from "../../../http";
4
4
  import { CATEGORY_ENDPOINTS } from "../../../constants/api-endpoints";
5
+ import { isListingCategory } from "../constants/listing-categories";
5
6
  /**
6
7
  * Returns all categories flattened in DFS tree order:
7
8
  * parent → its children → their children, etc.
@@ -11,11 +12,36 @@ export function useCategoryTree(opts) {
11
12
  const { data, isLoading } = useQuery({
12
13
  queryKey: ["categories", "tree"],
13
14
  queryFn: async () => {
14
- const res = await apiClient.get(`${CATEGORY_ENDPOINTS.LIST}?pageSize=300&sort=tier,order,name`);
15
+ /*
16
+ * 🛑 `flat=true` is load-bearing, not a preference.
17
+ *
18
+ * `/api/categories` computes `isFiltered` from flat/parentId/featured/
19
+ * isBrand/showOnHomepage/tier/type. `pageSize` and `sort` are NOT in
20
+ * that set — so a request carrying only those falls through to TREE
21
+ * mode, which returns a NESTED payload with only roots at the top
22
+ * level. `flattenTree` below then builds its adjacency map from roots
23
+ * alone and `dfs(null)` emits tier-0 only.
24
+ *
25
+ * Measured against production data before this fix: the category
26
+ * facet on /products, /auctions, /pre-orders and /prize-draws showed
27
+ * **11 options instead of 47** — and 9 of those 11 were not categories
28
+ * at all. It listed "Takara-Tomy", "Hasbro", "Burst Battlers Pack" and
29
+ * "X-Series Starter Set", because brand and bundle rows live in the
30
+ * same collection with empty `parentIds`, so tree mode surfaced them
31
+ * as roots.
32
+ *
33
+ * It failed silently because a short list of plausible-looking names
34
+ * is indistinguishable from a correct one.
35
+ *
36
+ * `sort` was also dropped: BOTH branches hardcode `sort: "order"`, and
37
+ * this hook re-orders by DFS anyway, so passing it implied a control
38
+ * that never existed.
39
+ */
40
+ const res = await apiClient.get(`${CATEGORY_ENDPOINTS.LIST}?flat=true&pageSize=500`);
15
41
  const items = Array.isArray(res)
16
42
  ? res
17
43
  : res.items ?? res.data ?? [];
18
- return flattenTree(items);
44
+ return flattenTree(items.filter(isListingCategory));
19
45
  },
20
46
  enabled: opts?.enabled ?? true,
21
47
  staleTime: 5 * 60 * 1000, // 5 min
@@ -1,25 +1,87 @@
1
1
  import { BaseRepository, type FirebaseSieveFields, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
2
2
  import { type CategoryCreateInput, type CategoryDocument, type CategoryMoveInput, type CategoryTreeNode } from "../schemas";
3
- import type { FirestoreDocument } from "@mohasinac/appkit";
3
+ import type { FirestoreDocument, JsonValue } from "@mohasinac/appkit";
4
4
  export declare class CategoriesRepository extends BaseRepository<CategoryDocument> {
5
5
  static readonly SIEVE_FIELDS: FirebaseSieveFields;
6
6
  constructor();
7
- list(model: SieveModel): Promise<FirebaseSieveResult<CategoryDocument>>;
7
+ /** Derived on every write path via `applyWriteHooks`. */
8
+ protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
9
+ list(model: SieveModel, opts?: {
10
+ search?: string;
11
+ }): Promise<FirebaseSieveResult<CategoryDocument>>;
8
12
  createWithHierarchy(input: CategoryCreateInput): Promise<CategoryDocument>;
9
13
  getCategoryBySlug(slug: string): Promise<CategoryDocument | null>;
10
14
  getRootCategories(): Promise<CategoryDocument[]>;
11
15
  getLeafCategories(): Promise<CategoryDocument[]>;
12
- getCategoriesByTier(tier: number): Promise<CategoryDocument[]>;
13
- getCategoriesByRootId(rootId: string): Promise<CategoryDocument[]>;
14
- getChildren(parentId: string): Promise<CategoryDocument[]>;
15
16
  /**
16
- * IDs of every descendant category at any depth (not just direct
17
- * children) — `parentIds` stores the full ancestor chain, so a single
18
- * array-contains query on that field already returns the whole subtree
19
- * with no recursion needed. Used to expand a parent category's product
20
- * listing/count to include products filed under any of its children.
17
+ * @param limit 0 = unbounded (the historical behaviour). When > 0 the bound is
18
+ * pushed into Firestore instead of slicing after the read.
19
+ *
20
+ * 🛑 The limit is applied with HEADROOM, not exactly, because `!isBrand` is a
21
+ * post-fetch filter: brand rows live in this same collection (they are
22
+ * `CategoryDocument`s discriminated by `isBrand`), so a bare `.limit(n)` could
23
+ * return n brand rows, filter all of them out, and yield an empty tier. The
24
+ * caller still slices to its own limit.
25
+ */
26
+ getCategoriesByTier(tier: number, limit?: number): Promise<CategoryDocument[]>;
27
+ /**
28
+ * Every category sharing a root, tier-then-order.
29
+ *
30
+ * 🛑 `limit` is for DISPLAY callers only, and it is a real truncation.
31
+ * `buildTree(rootId)` must pass nothing: a tree built from a truncated node
32
+ * list silently drops whole branches, which is the exact failure
33
+ * `getDescendants`'s docstring was rewritten to avoid. A display grid of
34
+ * sibling categories, by contrast, renders ~20 tiles and has no reason to
35
+ * read 1,500 documents to do it.
36
+ *
37
+ * Paginated rather than one unbounded `.get()`: the previous form read the
38
+ * whole root subtree into memory on every category page render, which at the
39
+ * ~330-node forest this plan seeds is ~9% of a 50K/day Firestore budget for
40
+ * ONE page view.
41
+ */
42
+ /**
43
+ * Several categories by id, in ONE round trip, in the order asked for.
44
+ *
45
+ * The ancestor-walk primitive. A five-tier tree means a leaf has up to four
46
+ * ancestors, and resolving an inherited description template or an inherited
47
+ * `productDefaults` means reading them nearest-first until the answer is
48
+ * found — which as four sequential `findById` calls is four round trips, on
49
+ * a route the seller form hits on every category change. Rule #6 budgets
50
+ * about three for a whole request.
51
+ *
52
+ * 🛑 Order is PRESERVED and missing ids are DROPPED, not nulled. Callers
53
+ * walk the result looking for the first document that declares a thing, so
54
+ * "nearest first" has to survive the round trip — Firestore's `getAll`
55
+ * already answers in argument order, and this keeps that guarantee explicit
56
+ * rather than incidental. A caller needing to know WHICH id was missing
57
+ * should compare lengths; none does, because an ancestor that no longer
58
+ * exists is a data problem for the reconcile job, not a reason to fail a
59
+ * form.
60
+ *
61
+ * Bounded at 30 to match `array-contains-any`'s cap, so this cannot become
62
+ * the unbounded read that `getCategoriesByRootId` had to be rewritten to
63
+ * avoid being.
64
+ */
65
+ findByIds(ids: string[]): Promise<CategoryDocument[]>;
66
+ getCategoriesByRootId(rootId: string, opts?: {
67
+ limit?: number;
68
+ }): Promise<CategoryDocument[]>;
69
+ /**
70
+ * Categories whose `productDefaults.taxCodeId` points at `taxCodeId`.
71
+ *
72
+ * 🛑 Exists so DELETE on a tax code can REFUSE while it is referenced.
73
+ * `taxCodeId` is a plain string with no referential integrity, and a
74
+ * dangling reference does not error — `findResolvable` answers `null`,
75
+ * whose correct handling is "leave the product's gstRate alone", so every
76
+ * subsequent listing under that category silently derives NO TAX. It does
77
+ * not fail; it under-charges, until someone reads an invoice.
78
+ *
79
+ * One equality on a dot-path — served by the automatic index, no composite
80
+ * to declare. Bounded at 200: the answer is used to NAME referrers in a 409
81
+ * message, and the first five are what the admin reads.
21
82
  */
22
- getDescendantIds(categoryId: string): Promise<string[]>;
83
+ findByTaxCodeId(taxCodeId: string): Promise<CategoryDocument[]>;
84
+ getChildren(parentId: string): Promise<CategoryDocument[]>;
23
85
  /**
24
86
  * Every descendant DOCUMENT at any depth.
25
87
  *
@@ -159,7 +221,6 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
159
221
  * SB-UNI-B — derive the canonical `sublisting-{slug}` ID from a
160
222
  * human-entered category name.
161
223
  */
162
- incrementViewCount(categoryId: string): Promise<void>;
163
224
  generateSublistingId(name: string): string;
164
225
  }
165
226
  export declare const categoriesRepository: CategoriesRepository;