@mohasinac/appkit 4.43.0 → 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 (220) hide show
  1. package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
  2. package/dist/_internal/server/features/categories/adapters.js +235 -0
  3. package/dist/_internal/server/features/checkout/actions.js +161 -22
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
  5. package/dist/_internal/server/features/orders/adapters.js +21 -0
  6. package/dist/_internal/server/features/refunds/actions.js +1 -1
  7. package/dist/_internal/server/features/seo/sitemap.js +54 -15
  8. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  9. package/dist/_internal/server/features/stores/adapters.js +6 -0
  10. package/dist/_internal/server/functions/scheduled.d.ts +4 -2
  11. package/dist/_internal/server/functions/scheduled.js +64 -6
  12. package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
  13. package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
  14. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
  15. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
  16. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
  17. package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
  18. package/dist/_internal/server/jobs/core/onCategoryWrite.js +54 -0
  19. package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
  20. package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
  21. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
  22. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
  23. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
  24. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
  25. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
  26. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
  27. package/dist/_internal/server/jobs/handlers/index.js +3 -1
  28. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
  29. package/dist/_internal/server/jobs/handlers/messages.js +5 -2
  30. package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
  31. package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
  32. package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
  33. package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
  34. package/dist/_internal/shared/actions/action-registry.js +34 -0
  35. package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
  36. package/dist/_internal/shared/checkout/order-math.js +194 -7
  37. package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
  38. package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
  39. package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
  40. package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
  41. package/dist/_internal/shared/fees/calculator.d.ts +105 -0
  42. package/dist/_internal/shared/fees/calculator.js +143 -0
  43. package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
  44. package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
  45. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
  46. package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
  47. package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
  48. package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
  49. package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
  50. package/dist/_internal/shared/templating/placeholders.js +73 -0
  51. package/dist/client.d.ts +2 -0
  52. package/dist/client.js +1 -0
  53. package/dist/constants/api-endpoints.d.ts +42 -0
  54. package/dist/constants/api-endpoints.js +14 -0
  55. package/dist/features/admin/actions/admin-actions.d.ts +1 -1
  56. package/dist/features/admin/actions/admin-actions.js +1 -1
  57. package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
  58. package/dist/features/admin/components/AdminProductEditorView.js +8 -1
  59. package/dist/features/admin/components/AdminProductsView.js +78 -7
  60. package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
  61. package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
  62. package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
  63. package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
  64. package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
  65. package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
  66. package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
  67. package/dist/features/auth/schemas/firestore.d.ts +0 -1
  68. package/dist/features/blog/schemas/firestore.d.ts +2 -2
  69. package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
  70. package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
  71. package/dist/features/categories/api/[id]/description-template/route.js +252 -0
  72. package/dist/features/categories/api/route.js +156 -11
  73. package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
  74. package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
  75. package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
  76. package/dist/features/categories/constants/listing-categories.d.ts +17 -0
  77. package/dist/features/categories/constants/listing-categories.js +53 -0
  78. package/dist/features/categories/hooks/useCategoryTree.js +28 -2
  79. package/dist/features/categories/repository/categories.repository.d.ts +62 -11
  80. package/dist/features/categories/repository/categories.repository.js +178 -35
  81. package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
  82. package/dist/features/categories/repository/price-index.repository.js +85 -0
  83. package/dist/features/categories/schemas/category-content.d.ts +206 -0
  84. package/dist/features/categories/schemas/category-content.js +28 -0
  85. package/dist/features/categories/schemas/firestore.d.ts +74 -5
  86. package/dist/features/categories/schemas/firestore.js +74 -3
  87. package/dist/features/categories/schemas/index.d.ts +1047 -0
  88. package/dist/features/categories/schemas/index.js +96 -0
  89. package/dist/features/categories/server.d.ts +1 -0
  90. package/dist/features/categories/server.js +7 -0
  91. package/dist/features/categories/types/index.d.ts +9 -0
  92. package/dist/features/events/schemas/firestore.d.ts +2 -2
  93. package/dist/features/faq/schemas/firestore.d.ts +2 -1
  94. package/dist/features/faq/schemas/firestore.js +20 -8
  95. package/dist/features/orders/schemas/firestore.d.ts +70 -0
  96. package/dist/features/orders/types/index.d.ts +55 -0
  97. package/dist/features/products/columns/index.d.ts +0 -1
  98. package/dist/features/products/columns/index.js +5 -1
  99. package/dist/features/products/components/ProductFilters.d.ts +0 -9
  100. package/dist/features/products/components/ProductForm.d.ts +27 -1
  101. package/dist/features/products/components/ProductForm.js +26 -12
  102. package/dist/features/products/constants/sieve.d.ts +0 -6
  103. package/dist/features/products/constants/sieve.js +35 -2
  104. package/dist/features/products/repository/products.repository.d.ts +115 -1
  105. package/dist/features/products/repository/products.repository.js +491 -39
  106. package/dist/features/products/schemas/firestore.d.ts +65 -5
  107. package/dist/features/products/schemas/firestore.js +9 -0
  108. package/dist/features/products/schemas/product-features.d.ts +140 -2
  109. package/dist/features/products/schemas/product-features.js +62 -1
  110. package/dist/features/promotions/repository/coupons.repository.js +6 -1
  111. package/dist/features/promotions/schemas/firestore.d.ts +44 -1
  112. package/dist/features/promotions/schemas/firestore.js +76 -2
  113. package/dist/features/seller/actions/seller-actions.js +17 -2
  114. package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
  115. package/dist/features/seller/components/PrintCenterView.js +3 -2
  116. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
  117. package/dist/features/seller/components/SellerOrdersView.js +6 -1
  118. package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
  119. package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
  120. package/dist/features/seller/components/SellerProductShell.js +101 -9
  121. package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
  122. package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
  123. package/dist/features/seller/components/SellerProductsView.js +108 -3
  124. package/dist/features/seller/types/index.d.ts +17 -0
  125. package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
  126. package/dist/features/seller/utils/product-draft-mapping.js +17 -0
  127. package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
  128. package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
  129. package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
  130. package/dist/features/stores/schemas/firestore.d.ts +31 -2
  131. package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
  132. package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
  133. package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
  134. package/dist/features/tax-codes/schemas/firestore.js +82 -0
  135. package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
  136. package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
  137. package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
  138. package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
  139. package/dist/features/tester/actions/index.d.ts +0 -1
  140. package/dist/features/tester/actions/index.js +0 -1
  141. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
  142. package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
  143. package/dist/features/tester/components/index.d.ts +0 -4
  144. package/dist/features/tester/components/index.js +0 -2
  145. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
  146. package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
  147. package/dist/features/tester/schemas/firestore.d.ts +0 -6
  148. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
  149. package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
  150. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
  151. package/dist/features/tester/seed-data/authored/index.js +0 -2
  152. package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
  153. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +8 -27
  154. package/dist/index.d.ts +21 -7
  155. package/dist/index.js +53 -7
  156. package/dist/next/routing/route-map.d.ts +2 -4
  157. package/dist/next/routing/route-map.js +1 -2
  158. package/dist/repositories/index.d.ts +1 -0
  159. package/dist/repositories/index.js +1 -0
  160. package/dist/schemas/registry.d.ts +506 -0
  161. package/dist/seed/_helpers/brand-rows.d.ts +35 -0
  162. package/dist/seed/_helpers/brand-rows.js +576 -0
  163. package/dist/seed/_helpers/category-content.d.ts +39 -0
  164. package/dist/seed/_helpers/category-content.js +634 -0
  165. package/dist/seed/_helpers/category-forest.d.ts +2 -0
  166. package/dist/seed/_helpers/category-forest.js +585 -243
  167. package/dist/seed/_helpers/category-models.d.ts +5 -0
  168. package/dist/seed/_helpers/category-models.js +1364 -0
  169. package/dist/seed/_helpers/category-tree.js +17 -1
  170. package/dist/seed/_helpers/character-features.d.ts +10 -0
  171. package/dist/seed/_helpers/character-features.js +118 -0
  172. package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
  173. package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
  174. package/dist/seed/categories-seed-data.js +147 -169
  175. package/dist/seed/index.d.ts +1 -0
  176. package/dist/seed/index.js +1 -0
  177. package/dist/seed/manifest.d.ts +1 -1
  178. package/dist/seed/manifest.js +5 -0
  179. package/dist/seed/product-features-seed-data.d.ts +1 -8
  180. package/dist/seed/product-features-seed-data.js +358 -154
  181. package/dist/seed/products-art-seed-data.js +16 -4
  182. package/dist/seed/products-auctions-seed-data.js +6 -6
  183. package/dist/seed/products-classifieds-seed-data.js +17 -7
  184. package/dist/seed/products-digital-codes-seed-data.js +12 -12
  185. package/dist/seed/products-preorders-seed-data.js +8 -8
  186. package/dist/seed/products-prize-draws-seed-data.js +25 -8
  187. package/dist/seed/products-standard-seed-data.js +10 -60
  188. package/dist/seed/products-stickers-seed-data.js +6 -6
  189. package/dist/seed/site-settings-seed-data.js +24 -0
  190. package/dist/seed/store-extensions-seed-data.js +10 -4
  191. package/dist/seed/tax-codes-seed-data.d.ts +34 -0
  192. package/dist/seed/tax-codes-seed-data.js +100 -0
  193. package/dist/seed/test-data-ttl.d.ts +29 -0
  194. package/dist/seed/test-data-ttl.js +31 -0
  195. package/dist/server-entry.d.ts +1 -1
  196. package/dist/server-entry.js +6 -1
  197. package/dist/server.d.ts +2 -1
  198. package/dist/server.js +5 -2
  199. package/dist/styles.css +40 -0
  200. package/dist/ui/components/PaginatedSelect.d.ts +14 -0
  201. package/dist/ui/components/PaginatedSelect.js +0 -0
  202. package/dist/ui/components/PaginatedSelect.style.css +40 -0
  203. package/dist/utils/physical-location.d.ts +23 -0
  204. package/dist/utils/physical-location.js +52 -0
  205. package/dist/utils/search-txt-builders.d.ts +20 -0
  206. package/dist/utils/search-txt-builders.js +36 -0
  207. package/package.json +1 -1
  208. package/scripts/seed-cli.mjs +4 -1
  209. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
  210. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
  211. package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
  212. package/dist/features/tester/actions/leaderboard-actions.js +0 -5
  213. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
  214. package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
  215. package/dist/features/tester/components/TesterHubView.d.ts +0 -5
  216. package/dist/features/tester/components/TesterHubView.js +0 -110
  217. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
  218. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
  219. package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
  220. package/dist/features/tester/seed-data/tester-ttl.js +0 -6
@@ -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,10 +1,14 @@
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[]>;
@@ -20,16 +24,64 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
20
24
  * caller still slices to its own limit.
21
25
  */
22
26
  getCategoriesByTier(tier: number, limit?: number): Promise<CategoryDocument[]>;
23
- getCategoriesByRootId(rootId: string): Promise<CategoryDocument[]>;
24
- getChildren(parentId: string): Promise<CategoryDocument[]>;
25
27
  /**
26
- * IDs of every descendant category at any depth (not just direct
27
- * children) — `parentIds` stores the full ancestor chain, so a single
28
- * array-contains query on that field already returns the whole subtree
29
- * with no recursion needed. Used to expand a parent category's product
30
- * listing/count to include products filed under any of its children.
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.
31
64
  */
32
- getDescendantIds(categoryId: string): Promise<string[]>;
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.
82
+ */
83
+ findByTaxCodeId(taxCodeId: string): Promise<CategoryDocument[]>;
84
+ getChildren(parentId: string): Promise<CategoryDocument[]>;
33
85
  /**
34
86
  * Every descendant DOCUMENT at any depth.
35
87
  *
@@ -169,7 +221,6 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
169
221
  * SB-UNI-B — derive the canonical `sublisting-{slug}` ID from a
170
222
  * human-entered category name.
171
223
  */
172
- incrementViewCount(categoryId: string): Promise<void>;
173
224
  generateSublistingId(name: string): string;
174
225
  }
175
226
  export declare const categoriesRepository: CategoriesRepository;
@@ -1,15 +1,45 @@
1
1
  import { normalizeError } from "../../../errors/normalize";
2
2
  import { increment, arrayUnion, arrayRemove, } from "../../../contracts/field-ops";
3
3
  import { DatabaseError } from "../../../errors";
4
- import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
4
+ import { BaseRepository, prepareForFirestore, parseSieveDateValue, applySieveToFirestore, } from "../../../providers/db-firebase";
5
+ import { buildCategorySearchTxt } from "../../../utils/search-txt-builders";
6
+ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
5
7
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
6
8
  import { CATEGORY_FIELDS, CATEGORIES_COLLECTION, MIN_ITEMS_FOR_FEATURED, buildCategoryTree, calculateCategoryFields, canBeFeatured, createCategoryId, isValidCategoryMove, } from "../schemas";
7
9
  export class CategoriesRepository extends BaseRepository {
8
10
  constructor() {
9
11
  super(CATEGORIES_COLLECTION);
10
12
  }
11
- async list(model) {
12
- return this.sieveQuery(model, CategoriesRepository.SIEVE_FIELDS);
13
+ /** Derived on every write path via `applyWriteHooks`. */
14
+ buildSearchTxtFor(data) {
15
+ return buildCategorySearchTxt(data);
16
+ }
17
+ async list(model, opts) {
18
+ /*
19
+ * `q` is a searchTxt pushdown, NOT a Sieve filter.
20
+ *
21
+ * `plan.head` becomes one `array-contains` on the longest term and
22
+ * `plan.rest` is AND-refined in memory — the same two-part shape the other
23
+ * ten migrated collections use. Going through `planSearchTxt` rather than
24
+ * hand-rolling it is what buys the `empty` guard: a query that normalises
25
+ * to no usable token (`"!!!"`, `"—"`) must return NOTHING, because
26
+ * emitting no clause at all returns the whole collection and calls it a
27
+ * search result. That is the live bug `products` still has (A2).
28
+ */
29
+ const plan = planSearchTxt(opts?.search);
30
+ if (plan.empty)
31
+ return emptySearchResult();
32
+ if (!plan.head) {
33
+ return this.sieveQuery(model, CategoriesRepository.SIEVE_FIELDS);
34
+ }
35
+ const baseQuery = this.getCollection().where(CATEGORY_FIELDS.SEARCH_TXT, "array-contains", plan.head);
36
+ const result = await applySieveToFirestore({
37
+ baseQuery,
38
+ model,
39
+ fields: CategoriesRepository.SIEVE_FIELDS,
40
+ mapDoc: (snap) => this.mapDoc(snap),
41
+ });
42
+ return refineSearchTxt(result, plan.rest);
13
43
  }
14
44
  async createWithHierarchy(input) {
15
45
  try {
@@ -141,21 +171,139 @@ export class CategoriesRepository extends BaseRepository {
141
171
  throw new DatabaseError(`Failed to retrieve categories by tier: ${error instanceof Error ? error.message : "Unknown error"}`);
142
172
  }
143
173
  }
144
- async getCategoriesByRootId(rootId) {
174
+ /**
175
+ * Every category sharing a root, tier-then-order.
176
+ *
177
+ * 🛑 `limit` is for DISPLAY callers only, and it is a real truncation.
178
+ * `buildTree(rootId)` must pass nothing: a tree built from a truncated node
179
+ * list silently drops whole branches, which is the exact failure
180
+ * `getDescendants`'s docstring was rewritten to avoid. A display grid of
181
+ * sibling categories, by contrast, renders ~20 tiles and has no reason to
182
+ * read 1,500 documents to do it.
183
+ *
184
+ * Paginated rather than one unbounded `.get()`: the previous form read the
185
+ * whole root subtree into memory on every category page render, which at the
186
+ * ~330-node forest this plan seeds is ~9% of a 50K/day Firestore budget for
187
+ * ONE page view.
188
+ */
189
+ /**
190
+ * Several categories by id, in ONE round trip, in the order asked for.
191
+ *
192
+ * The ancestor-walk primitive. A five-tier tree means a leaf has up to four
193
+ * ancestors, and resolving an inherited description template or an inherited
194
+ * `productDefaults` means reading them nearest-first until the answer is
195
+ * found — which as four sequential `findById` calls is four round trips, on
196
+ * a route the seller form hits on every category change. Rule #6 budgets
197
+ * about three for a whole request.
198
+ *
199
+ * 🛑 Order is PRESERVED and missing ids are DROPPED, not nulled. Callers
200
+ * walk the result looking for the first document that declares a thing, so
201
+ * "nearest first" has to survive the round trip — Firestore's `getAll`
202
+ * already answers in argument order, and this keeps that guarantee explicit
203
+ * rather than incidental. A caller needing to know WHICH id was missing
204
+ * should compare lengths; none does, because an ancestor that no longer
205
+ * exists is a data problem for the reconcile job, not a reason to fail a
206
+ * form.
207
+ *
208
+ * Bounded at 30 to match `array-contains-any`'s cap, so this cannot become
209
+ * the unbounded read that `getCategoriesByRootId` had to be rewritten to
210
+ * avoid being.
211
+ */
212
+ async findByIds(ids) {
213
+ const unique = Array.from(new Set(ids.filter(Boolean))).slice(0, 30);
214
+ if (unique.length === 0)
215
+ return [];
145
216
  try {
146
- const snapshot = await this.db
147
- .collection(this.collection)
148
- .where(CATEGORY_FIELDS.ROOT_ID, "==", rootId)
149
- .orderBy(CATEGORY_FIELDS.TIER, "asc")
150
- .orderBy(CATEGORY_FIELDS.ORDER, "asc")
151
- .get();
152
- return snapshot.docs.map((doc) => this.mapDoc(doc));
217
+ const refs = unique.map((id) => this.db.collection(this.collection).doc(id));
218
+ const snaps = await this.db.getAll(...refs);
219
+ return snaps
220
+ .filter((s) => s.exists)
221
+ .map((s) => this.mapDoc(s));
222
+ }
223
+ catch (err) {
224
+ const e = normalizeError(err);
225
+ /*
226
+ * 🛑 The raw `.message` is NOT interpolated into the thrown error — only
227
+ * the normalised `kind` and a count.
228
+ *
229
+ * Several older methods in this file do interpolate it, and
230
+ * `audit-raw-error-text` flagged this one for trying. The reasoning is
231
+ * Root Cause #86's: a thrown value's message is written for a developer,
232
+ * and this one reaches a user through the route's 500 branch — which is
233
+ * how a Node `MODULE_NOT_FOUND` with a full require stack ended up
234
+ * rendered inside the "Place your bid" modal.
235
+ *
236
+ * The ids are counted rather than listed. A category id is not
237
+ * sensitive, but 30 of them in one line is noise against Hobby's ~4 KB/s
238
+ * log budget.
239
+ *
240
+ * (`normalizeError` has to be the FIRST statement, not merely nearby:
241
+ * `audit-catch-normalize` looks within a few lines of the catch, and the
242
+ * first draft put this comment above the call and tripped it.)
243
+ */
244
+ throw new DatabaseError(`Failed to read categories by id (${unique.length} requested): ${e.kind}`);
245
+ }
246
+ }
247
+ async getCategoriesByRootId(rootId, opts) {
248
+ try {
249
+ const cap = opts?.limit;
250
+ const pageSize = cap ? Math.min(cap, 300) : 300;
251
+ const out = [];
252
+ let cursor = null;
253
+ for (;;) {
254
+ let q = this.db
255
+ .collection(this.collection)
256
+ .where(CATEGORY_FIELDS.ROOT_ID, "==", rootId)
257
+ .orderBy(CATEGORY_FIELDS.TIER, "asc")
258
+ .orderBy(CATEGORY_FIELDS.ORDER, "asc")
259
+ .limit(pageSize);
260
+ if (cursor)
261
+ q = q.startAfter(cursor);
262
+ const page = await q.get();
263
+ if (page.empty)
264
+ break;
265
+ for (const doc of page.docs)
266
+ out.push(this.mapDoc(doc));
267
+ if (cap && out.length >= cap)
268
+ return out.slice(0, cap);
269
+ if (page.size < pageSize)
270
+ break;
271
+ cursor = page.docs[page.docs.length - 1];
272
+ }
273
+ return out;
153
274
  }
154
275
  catch (error) {
155
276
  void normalizeError(error);
156
277
  throw new DatabaseError(`Failed to retrieve categories by rootId: ${error instanceof Error ? error.message : "Unknown error"}`);
157
278
  }
158
279
  }
280
+ /**
281
+ * Categories whose `productDefaults.taxCodeId` points at `taxCodeId`.
282
+ *
283
+ * 🛑 Exists so DELETE on a tax code can REFUSE while it is referenced.
284
+ * `taxCodeId` is a plain string with no referential integrity, and a
285
+ * dangling reference does not error — `findResolvable` answers `null`,
286
+ * whose correct handling is "leave the product's gstRate alone", so every
287
+ * subsequent listing under that category silently derives NO TAX. It does
288
+ * not fail; it under-charges, until someone reads an invoice.
289
+ *
290
+ * One equality on a dot-path — served by the automatic index, no composite
291
+ * to declare. Bounded at 200: the answer is used to NAME referrers in a 409
292
+ * message, and the first five are what the admin reads.
293
+ */
294
+ async findByTaxCodeId(taxCodeId) {
295
+ try {
296
+ const snap = await this.getCollection()
297
+ .where(CATEGORY_FIELDS.PRODUCT_DEFAULTS_TAX_CODE_ID, "==", taxCodeId)
298
+ .limit(200)
299
+ .get();
300
+ return snap.docs.map((d) => this.mapDoc(d));
301
+ }
302
+ catch (error) {
303
+ void normalizeError(error);
304
+ throw new DatabaseError(`Failed to find categories by tax code: ${error instanceof Error ? error.message : "Unknown error"}`);
305
+ }
306
+ }
159
307
  async getChildren(parentId) {
160
308
  try {
161
309
  const snapshot = await this.db
@@ -173,17 +321,16 @@ export class CategoriesRepository extends BaseRepository {
173
321
  throw new DatabaseError(`Failed to retrieve children categories: ${error instanceof Error ? error.message : "Unknown error"}`);
174
322
  }
175
323
  }
176
- /**
177
- * IDs of every descendant category at any depth (not just direct
178
- * children) — `parentIds` stores the full ancestor chain, so a single
179
- * array-contains query on that field already returns the whole subtree
180
- * with no recursion needed. Used to expand a parent category's product
181
- * listing/count to include products filed under any of its children.
324
+ /*
325
+ * 🛑 `getDescendantIds` was DELETED (2026-10-10). It was
326
+ * `getDescendants(id).then(d => d.map(x => x.id))`, and its only caller —
327
+ * CategoryDetailPageView — already held the documents from its own
328
+ * `getDescendants` call in the same render. So it re-ran the identical
329
+ * paginated subtree query and discarded everything but the ids: ~330 wasted
330
+ * reads per page view at the forest size this plan seeds, and two answers
331
+ * that could disagree whenever one read failed and the other did not.
332
+ * Call `getDescendants` and `.map(d => d.id)` at the one site that needs it.
182
333
  */
183
- async getDescendantIds(categoryId) {
184
- const docs = await this.getDescendants(categoryId);
185
- return docs.map((d) => d.id);
186
- }
187
334
  /**
188
335
  * Every descendant DOCUMENT at any depth.
189
336
  *
@@ -696,24 +843,19 @@ export class CategoriesRepository extends BaseRepository {
696
843
  .get();
697
844
  return snap.docs.map((d) => this.mapDoc(d));
698
845
  }
846
+ /*
847
+ * 🛑 `incrementViewCount` DELETED 2026-10-10 — zero callers since
848
+ * 2026-08-31, when it was removed from the category-detail render path (a
849
+ * Firestore write per render, and a second counter racing `pageViews`).
850
+ *
851
+ * Measured: `viewCount` is absent from all 58 category documents. Deleting
852
+ * the writer nobody calls is what stops the dead field reading as live. See
853
+ * the products repository for the same removal.
854
+ */
699
855
  /**
700
856
  * SB-UNI-B — derive the canonical `sublisting-{slug}` ID from a
701
857
  * human-entered category name.
702
858
  */
703
- async incrementViewCount(categoryId) {
704
- try {
705
- await this.db
706
- .collection(this.collection)
707
- .doc(categoryId)
708
- .update({ [CATEGORY_FIELDS.VIEW_COUNT]: increment(1) });
709
- // Fire-and-forget analytics: a lost view increment changes nothing that
710
- // renders, so it must never break the category read path it rides along
711
- // with — the count is approximate by design.
712
- }
713
- catch (_err) {
714
- void normalizeError(_err);
715
- }
716
- }
717
859
  generateSublistingId(name) {
718
860
  const base = name
719
861
  .toLowerCase()
@@ -730,6 +872,7 @@ CategoriesRepository.SIEVE_FIELDS = {
730
872
  isActive: { canFilter: true, canSort: false },
731
873
  isFeatured: { canFilter: true, canSort: false },
732
874
  isBrand: { canFilter: true, canSort: false },
875
+ searchTxt: { canFilter: true, canSort: false },
733
876
  categoryType: { canFilter: true, canSort: false },
734
877
  isSearchable: { canFilter: true, canSort: false },
735
878
  parentId: { canFilter: true, canSort: false, path: "parentIds" },