@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,252 @@
1
+ /**
2
+ * feat-categories — GET /api/categories/[id]/description-template
3
+ *
4
+ * The ONE read the seller form makes when a category or condition changes.
5
+ *
6
+ * ```ts
7
+ * // app/api/categories/[id]/description-template/route.ts
8
+ * export { categoryDescriptionTemplateGET as GET } from "@mohasinac/appkit";
9
+ * ```
10
+ *
11
+ * ## Why an endpoint rather than letting the client walk the tree
12
+ *
13
+ * A description template lives on the category and INHERITS: a named-model
14
+ * leaf like `category-dragoon-g` carries no body of its own, and the right one
15
+ * is on the `category-original-engine-gear` line above it. Resolving that on
16
+ * the client would be one request per ancestor, on every category change, from
17
+ * a form — up to five round trips to answer one question.
18
+ *
19
+ * Here it is **two reads worst case and zero on a cache hit**: the leaf, then
20
+ * one bounded `getAll` over at most four ancestors, behind a one-hour edge
21
+ * cache. The response depends only on `(id, variant)` and category content
22
+ * changes when an admin edits it, so an hour of staleness costs a seller
23
+ * nothing and saves a function invocation per keystroke-free form open.
24
+ *
25
+ * ## 🛑 NO MONEY IN THIS PAYLOAD
26
+ *
27
+ * `Cache-Control: public` means a CDN holds this and anyone can fetch it, so
28
+ * `priceGuidance` is deliberately stripped even though it sits in the same
29
+ * `productDefaults` object. The band is competitor-derived market data, not a
30
+ * listing price — but prices are withheld from signed-out visitors precisely
31
+ * because hiding one number is pointless if a neighbouring surface publishes
32
+ * the range, and a publicly-cached endpoint is exactly such a surface. The
33
+ * seller's price hint is C1's job, from a surface that knows who is asking.
34
+ *
35
+ * `taxCodeId` is stripped for a different reason: nothing on the client reads
36
+ * it. The rate is resolved server-side by `deriveTaxonomy`, which is the whole
37
+ * point of the reference being server-resolved (C2a) — a client that knew the
38
+ * code could not do anything correct with it.
39
+ */
40
+ import { NextResponse } from "next/server.js";
41
+ import { normalizeError } from "../../../../../errors/normalize";
42
+ import { serverLogger } from "../../../../../monitoring";
43
+ /*
44
+ * The repository directly, which is the established shape for an appkit API
45
+ * route (blog/[slug], products/[id] and seller/products all do the same). The
46
+ * `getProviders()` contract exposes only the generic `IRepository`, so the
47
+ * bounded `findByIds` ancestor walk this route needs is not reachable through
48
+ * it — and the alternative is four sequential reads on a route the seller form
49
+ * hits on every category change.
50
+ */
51
+ import { categoriesRepository } from "../../../repository/categories.repository";
52
+ import { usedPlaceholders } from "../../../../../_internal/shared/templating/placeholders";
53
+ /**
54
+ * The variants a caller may ask for.
55
+ *
56
+ * 🛑 Validated against the union rather than passed through. An unrecognised
57
+ * variant must fall back to `default` and not reach a `templates.find()` that
58
+ * would answer `null` — because `null` here means "this category has no
59
+ * template", which the picker renders as "No template yet". A typo'd variant
60
+ * would therefore look like missing content rather than a bad request, and
61
+ * nobody would ever find it.
62
+ */
63
+ const VARIANTS = [
64
+ "default",
65
+ "new_in_box",
66
+ "new_in_packet",
67
+ "pre_owned",
68
+ "replica",
69
+ ];
70
+ /** At most four ancestors on a five-tier tree, so the walk is bounded by shape. */
71
+ const MAX_ANCESTORS = 8;
72
+ function toPublicDefaults(d) {
73
+ if (!d)
74
+ return {};
75
+ /*
76
+ * An explicit allow-list, not a spread-and-delete. Root Cause #70: a
77
+ * deny-list keyed on a TypeScript interface is structurally blind to fields
78
+ * the interface does not declare, and this object gains fields over time.
79
+ */
80
+ const out = {};
81
+ if (d.specifications?.length)
82
+ out.specifications = d.specifications;
83
+ if (d.inTheBox?.length)
84
+ out.inTheBox = d.inTheBox;
85
+ if (d.defaultCondition)
86
+ out.defaultCondition = d.defaultCondition;
87
+ if (d.defaultAuthenticity)
88
+ out.defaultAuthenticity = d.defaultAuthenticity;
89
+ if (d.seoKeywords?.length)
90
+ out.seoKeywords = d.seoKeywords;
91
+ if (d.faqTemplates?.length)
92
+ out.faqTemplates = d.faqTemplates;
93
+ return out;
94
+ }
95
+ function pickTemplate(doc, variant) {
96
+ const all = doc?.descriptionTemplates;
97
+ if (!all?.length)
98
+ return null;
99
+ /*
100
+ * The asked-for variant, else the category's `default`, else nothing. NOT
101
+ * "the first template in the array": a category offering a reproduction
102
+ * body and a new-in-box body has no natural first, and answering with
103
+ * whichever the author happened to list first would silently hand a
104
+ * brand-new listing the reproduction disclosure.
105
+ */
106
+ return (all.find((t) => t.variant === variant) ??
107
+ all.find((t) => t.variant === "default") ??
108
+ null);
109
+ }
110
+ /**
111
+ * The `productDefaults` fields the picker can inherit, resolved INDIVIDUALLY.
112
+ *
113
+ * 🛑 PER FIELD, not per object, and the first version of this route got it
114
+ * wrong in exactly the way C2a's `applyCategoryDefaults` was written to avoid.
115
+ *
116
+ * A named-model leaf DOES carry a `productDefaults` object — its
117
+ * `defaultFeatures` (blader, battle type, spin) — while the spec rows and the
118
+ * in-the-box list live on the tier-3 line above it. Stopping at "the leaf has
119
+ * an object, so stop looking" therefore returned the blader and silently
120
+ * dropped the specs, so the picker could never say "this will add 2
121
+ * specification rows". Caught by a test asserting the picker still gets what
122
+ * it needs, which the money-stripping assertion sitting beside it would
123
+ * otherwise have made look fine.
124
+ */
125
+ const INHERITABLE_DEFAULT_KEYS = [
126
+ "specifications",
127
+ "inTheBox",
128
+ "defaultCondition",
129
+ "defaultAuthenticity",
130
+ "seoKeywords",
131
+ "faqTemplates",
132
+ ];
133
+ const isEmptyValue = (v) => v == null || (Array.isArray(v) && v.length === 0);
134
+ /** Fill only the keys still missing, from `src`. Returns what is still absent. */
135
+ function mergeDefaults(into, src) {
136
+ if (src) {
137
+ for (const k of INHERITABLE_DEFAULT_KEYS) {
138
+ if (isEmptyValue(into[k]) && !isEmptyValue(src[k])) {
139
+ into[k] = src[k];
140
+ }
141
+ }
142
+ }
143
+ return INHERITABLE_DEFAULT_KEYS.filter((k) => isEmptyValue(into[k]));
144
+ }
145
+ /**
146
+ * Walk the ancestors nearest-first for whatever the leaf did not supply.
147
+ *
148
+ * Extracted because `audit-code-quality` flagged the inline version at
149
+ * brace-depth 6, and it was right — "for each ancestor, if we still need a
150
+ * template, if this one has it" is three questions in one block, and the
151
+ * nesting is what hides the ordering guarantee below.
152
+ */
153
+ async function resolveInherited(parentIds, variant, seed) {
154
+ const out = { ...seed };
155
+ if (parentIds.length === 0)
156
+ return out;
157
+ // `parentIds` is root-first/nearest-last, so reverse it. ONE `getAll`: four
158
+ // sequential reads would be four round trips on a route the seller form hits
159
+ // on every category change.
160
+ const nearestFirst = [...parentIds].reverse();
161
+ const ancestors = await categoriesRepository.findByIds(nearestFirst);
162
+ /*
163
+ * 🛑 Re-indexed into the ASKED-FOR order rather than trusted.
164
+ *
165
+ * `findByIds` dedupes and drops ids that no longer exist, so its result is
166
+ * in request order but may be SHORTER — and "nearest ancestor wins" is the
167
+ * whole semantics here. Walking `nearestFirst` instead of the result means a
168
+ * missing ancestor cannot silently promote a more distant one to first
169
+ * place, which would hand a leaf the ROOT's generic body while reporting
170
+ * that it came from the line.
171
+ */
172
+ const byId = new Map(ancestors.map((a) => [a.id, a]));
173
+ for (const pid of nearestFirst) {
174
+ const d = byId.get(pid);
175
+ if (!d)
176
+ continue;
177
+ const found = out.template ? null : pickTemplate(d, variant);
178
+ if (found) {
179
+ out.template = found;
180
+ out.inheritedFrom = pid;
181
+ out.inheritedFromName = d.name ?? null;
182
+ }
183
+ // PER FIELD — see INHERITABLE_DEFAULT_KEYS. Stop only once the template is
184
+ // settled AND no inheritable default is still missing.
185
+ const stillMissing = mergeDefaults(out.defaults, d.productDefaults);
186
+ if (out.template && stillMissing.length === 0)
187
+ break;
188
+ }
189
+ return out;
190
+ }
191
+ export async function GET(request, context) {
192
+ try {
193
+ const { id } = await context.params;
194
+ const raw = new URL(request.url).searchParams.get("variant") ?? "default";
195
+ const variant = VARIANTS.includes(raw)
196
+ ? raw
197
+ : "default";
198
+ const leafDoc = await categoriesRepository.findById(id);
199
+ if (!leafDoc) {
200
+ return NextResponse.json({ success: false, error: "Category not found" }, { status: 404 });
201
+ }
202
+ const leaf = leafDoc;
203
+ const parentIds = (leaf.parentIds ?? []).slice(0, MAX_ANCESTORS);
204
+ /*
205
+ * The leaf's own defaults are COPIED into the accumulator rather than
206
+ * referenced, so the per-field merge below cannot mutate the document the
207
+ * repository cached.
208
+ */
209
+ const { template, inheritedFrom, inheritedFromName, defaults } = await resolveInherited(parentIds, variant, {
210
+ template: pickTemplate(leaf, variant),
211
+ inheritedFrom: null,
212
+ inheritedFromName: null,
213
+ defaults: { ...(leaf.productDefaults ?? {}) },
214
+ });
215
+ /*
216
+ * 🛑 `placeholders` is RE-DERIVED here and not trusted from the stored
217
+ * template.
218
+ *
219
+ * The field's own contract says absent means "re-parse", never "no slots",
220
+ * and the seeded bodies deliberately leave it unset so there is one
221
+ * implementation rather than a hand-written list that drifts the first time
222
+ * a body is edited. The picker needs the real list to report "2 of 9 slots
223
+ * could not be filled", and a stale list is how a literal `{{series}}`
224
+ * reaches a published description.
225
+ */
226
+ const placeholders = template ? usedPlaceholders(template.body) : [];
227
+ return NextResponse.json({
228
+ success: true,
229
+ data: {
230
+ template: template
231
+ ? { ...template, placeholders }
232
+ : null,
233
+ /** Null when the template came from the category itself. */
234
+ inheritedFrom,
235
+ inheritedFromName,
236
+ defaults: toPublicDefaults(defaults),
237
+ },
238
+ }, {
239
+ headers: {
240
+ // One hour at the edge. The response is a pure function of
241
+ // (id, variant) over admin-authored content; an hour of staleness
242
+ // costs a seller nothing and removes an invocation per form open.
243
+ "Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400",
244
+ },
245
+ });
246
+ }
247
+ catch (err) {
248
+ const e = normalizeError(err);
249
+ serverLogger.error("description-template read failed", { message: e.message });
250
+ return NextResponse.json({ success: false, error: "Could not load the category template" }, { status: 500 });
251
+ }
252
+ }
@@ -17,6 +17,10 @@ import { z } from "zod";
17
17
  import { mediaUrlSchema } from "../../../validation/schemas";
18
18
  import { getProviders } from "../../../contracts";
19
19
  import { createRouteHandler } from "../../../next";
20
+ import { toCategoryListItem } from "../../../_internal/server/features/categories/adapters";
21
+ import { isListingCategory } from "../constants/listing-categories";
22
+ import { planSearchTxt } from "../../../utils/search-txt-query";
23
+ import { matchesAllSearchTerms } from "../../../utils/search-txt";
20
24
  import { normalizeError } from "../../../errors/normalize";
21
25
  const CACHE_CONTROL_PUBLIC = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
22
26
  function buildTreeFromFlat(items, rootId) {
@@ -77,10 +81,65 @@ export async function GET(request) {
77
81
  const rawFilters = param(url, "filters");
78
82
  const tier = numParam(url, "tier");
79
83
  const pageSizeParam = numParam(url, "pageSize");
84
+ /*
85
+ * 🛑 `?page=` MEANS "give me a paged flat list", and reading it fixes two
86
+ * live bugs rather than adding a feature.
87
+ *
88
+ * (a) `useAdminListingData` ALWAYS sends `page` and `pageSize`, so
89
+ * `/admin/categories` has been requesting `?flat=true&page=N` while
90
+ * this handler ignored `page` entirely — the pager advanced the URL
91
+ * and returned page 1's rows every time. It also returned a bare
92
+ * array with no `total`, so `extractCategoryTotal` fell back to
93
+ * `mappedRows.length`, making `totalPages` 1 and disabling Next. The
94
+ * list showed 50 of 58 categories with no way to reach the rest.
95
+ *
96
+ * (b) `loadOptionsFrom` (the catalogue editor's category and brand
97
+ * pickers) sends ONLY `page`/`pageSize`/`q` — none of which `isFiltered`
98
+ * tested — so it fell through to TREE mode and then read `.items` off
99
+ * a bare array of nested nodes. `data.items ?? []` meant both pickers
100
+ * were permanently empty. Same shape as the `useCategoryTree` bug:
101
+ * `isFiltered` did not include the params real callers send.
102
+ */
103
+ const pageParam = numParam(url, "page");
104
+ const isPaged = pageParam !== null && pageParam > 0;
105
+ /*
106
+ * `?listingOnly=true` drops brand / bundle / sublisting rows.
107
+ *
108
+ * Applied in memory because the test cannot be a query clause — a plain
109
+ * listing category OMITS `categoryType`, so there is no value to match
110
+ * and an inequality would exclude every real category. See
111
+ * `constants/listing-categories.ts`.
112
+ *
113
+ * Server-side rather than per-caller: three surfaces had hand-written the
114
+ * same Set and the fourth (`loadCategoryOptions`, the catalogue editor's
115
+ * category picker) shipped without one, offering "Takara-Tomy" and
116
+ * "Original Collector's Set" as categories.
117
+ */
118
+ const listingOnly = param(url, "listingOnly") === "true";
119
+ /*
120
+ * `q` is read now that `searchTxt` exists.
121
+ *
122
+ * `loadOptionsFrom` has always sent it and this handler never read it, so
123
+ * the catalogue editor's category picker returned the same unfiltered page
124
+ * for every term — the search box accepted typing and changed nothing.
125
+ * Root Cause #62's shape, and the second half of why that picker was
126
+ * useless (the first being that it got a nested tree, §A1c).
127
+ */
128
+ const q = (param(url, "q") ?? "").trim();
80
129
  const { db } = getProviders();
81
130
  if (!db) {
82
131
  return NextResponse.json({ success: false, error: "Database provider not registered" }, { status: 503 });
83
132
  }
133
+ /*
134
+ * 🛑 Typed as the DOCUMENT, not as `CategoryItem`.
135
+ *
136
+ * This was `getRepository<CategoryItem>`, which is a cast-by-generic: the
137
+ * repository returns raw Firestore documents at runtime, so every return
138
+ * site below was handing out `createdBy`, `createdByStoreId` and the
139
+ * unbounded `metrics.productIds[]`/`auctionIds[]` while tsc believed the
140
+ * payload was already projected. Naming the real shape is what makes the
141
+ * missing `toCategoryListItem` calls a compile error instead of a leak.
142
+ */
84
143
  const repo = db.getRepository("categories");
85
144
  // -- Single slug lookup ----------------------------------------------------
86
145
  if (slug) {
@@ -92,7 +151,7 @@ export async function GET(request) {
92
151
  if (!category) {
93
152
  return NextResponse.json({ success: false, error: "Category not found" }, { status: 404 });
94
153
  }
95
- return NextResponse.json({ success: true, data: category });
154
+ return NextResponse.json({ success: true, data: toCategoryListItem(category) });
96
155
  }
97
156
  // -- Build Sieve filter string from query params ----------------------------
98
157
  const parts = [];
@@ -117,6 +176,7 @@ export async function GET(request) {
117
176
  // -- Filtered flat list modes -----------------------------------------------
118
177
  // If any filter is active, or ?flat=true → return flat array
119
178
  const isFiltered = flat === "true" ||
179
+ isPaged ||
120
180
  parentId !== null ||
121
181
  featured === "true" ||
122
182
  isBrand === "true" ||
@@ -125,14 +185,95 @@ export async function GET(request) {
125
185
  type !== null;
126
186
  if (isFiltered) {
127
187
  const perPage = pageSizeParam !== null && pageSizeParam > 0 ? pageSizeParam : 200;
128
- const result = await repo.findAll({
129
- filters,
130
- sort: "order",
131
- order: "asc",
132
- perPage,
133
- });
134
- const items = result.data.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
135
- const res = NextResponse.json({ success: true, data: items });
188
+ const page = pageParam ?? 1;
189
+ /*
190
+ * 🛑 `listingOnly` and `q` both force an in-memory window, and `total`
191
+ * is the reason.
192
+ *
193
+ * Each removes rows AFTER the query, so the repository's own `total`
194
+ * would be an over-count and `hasMore` would promise a "Load more" page
195
+ * that renders empty. Rather than ship a number that is wrong in a
196
+ * direction nobody can see, fetch one bounded window, filter it, and
197
+ * paginate the result — so `total` is exact.
198
+ *
199
+ * Affordable because this collection is small and hard-capped at 500 by
200
+ * `base.ts` anyway: 58 rows today, ~330 after the taxonomy seed. Past
201
+ * the cap the window truncates, which is the same ceiling the sitemap
202
+ * builder has and is tracked as its own item — it is not made worse
203
+ * here.
204
+ *
205
+ * The MATCH uses `matchesAllSearchTerms` over the stored `searchTxt`,
206
+ * not `name.includes()`, so this route and the admin route's real
207
+ * push-down agree on what a term means: word prefixes, accent-folded,
208
+ * all terms ANDed, lineage included via `ancestors[].name`. Two
209
+ * different notions of "matches" across two pickers over one collection
210
+ * is the drift this reuse avoids.
211
+ */
212
+ const plan = planSearchTxt(q);
213
+ // A query that normalised to no usable token must return NOTHING.
214
+ // Emitting no clause returns the whole collection and calls it a result.
215
+ if (plan.empty) {
216
+ const res = isPaged
217
+ ? NextResponse.json({
218
+ success: true,
219
+ data: { items: [], total: 0, page, perPage, totalPages: 0, hasMore: false },
220
+ })
221
+ : NextResponse.json({ success: true, data: [] });
222
+ res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
223
+ return res;
224
+ }
225
+ const terms = plan.head ? [plan.head, ...plan.rest] : [];
226
+ const needsWindow = listingOnly || terms.length > 0;
227
+ const windowed = needsWindow
228
+ ? await repo.findAll({ filters, sort: "order", order: "asc", perPage: 500 })
229
+ : null;
230
+ let items;
231
+ let total;
232
+ if (windowed) {
233
+ const kept = windowed.data
234
+ .filter((d) => (listingOnly ? isListingCategory(d) : true))
235
+ .filter((d) => (terms.length ? matchesAllSearchTerms(d.searchTxt, terms) : true))
236
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
237
+ total = kept.length;
238
+ const offset = (page - 1) * perPage;
239
+ items = (isPaged ? kept.slice(offset, offset + perPage) : kept).map(toCategoryListItem);
240
+ }
241
+ else {
242
+ const result = await repo.findAll({
243
+ filters,
244
+ sort: "order",
245
+ order: "asc",
246
+ page: pageParam ?? undefined,
247
+ perPage,
248
+ });
249
+ total = result.total;
250
+ items = result.data
251
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
252
+ .map(toCategoryListItem);
253
+ }
254
+ const totalPages = total === 0 ? 0 : Math.max(1, Math.ceil(total / perPage));
255
+ /*
256
+ * Shape switches on `?page=`, deliberately, and only there.
257
+ *
258
+ * A paged caller gets an envelope carrying `total`/`totalPages`/`hasMore`
259
+ * — without `total` the admin pager computes `totalPages = 1` from the
260
+ * row count and disables Next, which is the "50 of 58 rows" bug. A
261
+ * caller that sends no `page` keeps the bare array it has always had,
262
+ * because `useCategories` and `useTopCategories` are TYPED as
263
+ * `CategoryItem[]` and would throw on `.map` of an object (Root Cause
264
+ * #20 — a public shape change must update its call sites, and these
265
+ * callers never ask for paging, so the correct move is not to change
266
+ * their shape at all).
267
+ *
268
+ * `hasMore` exists because `loadOptionsFrom` reads it for "Load more";
269
+ * it used to read it off a bare array and always got `false`.
270
+ */
271
+ const res = isPaged
272
+ ? NextResponse.json({
273
+ success: true,
274
+ data: { items, total, page, perPage, totalPages, hasMore: page < totalPages },
275
+ })
276
+ : NextResponse.json({ success: true, data: items });
136
277
  res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
137
278
  return res;
138
279
  }
@@ -143,8 +284,12 @@ export async function GET(request) {
143
284
  order: "asc",
144
285
  perPage: 500,
145
286
  });
287
+ // Project BEFORE nesting — `CategoryTreeNode extends CategoryItem`, so the
288
+ // children carry the public shape too rather than being raw documents
289
+ // reachable one level down.
290
+ const allItems = allResult.data.map(toCategoryListItem);
146
291
  if (tree === "true" || !flat) {
147
- const treeNodes = buildTreeFromFlat(allResult.data, rootId);
292
+ const treeNodes = buildTreeFromFlat(allItems, rootId);
148
293
  const res = NextResponse.json({ success: true, data: treeNodes });
149
294
  res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
150
295
  return res;
@@ -153,7 +298,7 @@ export async function GET(request) {
153
298
  // because isFiltered catches it, but kept for clarity)
154
299
  const res = NextResponse.json({
155
300
  success: true,
156
- data: allResult.data.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
301
+ data: allItems.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
157
302
  });
158
303
  res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
159
304
  return res;
@@ -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;