@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,115 @@
1
+ /**
2
+ * Price Index Repository — what things actually SOLD for here, per leaf.
3
+ *
4
+ * ## 🛑 Why this is its own collection and not a field on `categories`
5
+ *
6
+ * The obvious design is `categories/{leafId}.productDefaults.priceGuidance.sold`,
7
+ * beside the seed-owned `market` band it is shown next to. That design is a
8
+ * runaway-trigger hazard and it is not a theoretical one.
9
+ *
10
+ * `onCategoryWrite` is a `documentWritten` trigger on `categories`, so a job
11
+ * writing a price band there re-triggers it once per document. That is exactly
12
+ * the shape of Root Cause #92, where `onShipmentHeaderWrite` wrote back to the
13
+ * collection it watched and produced **1,017,548 invocations in 24 hours**
14
+ * against a 2M/month allowance, blocked `firebase deploy` with a billing
15
+ * denial, and degraded production.
16
+ *
17
+ * It is also worse than that here, because `notifyRevalidate` fires on every
18
+ * category write — so each nightly price write would drop that category page's
19
+ * ISR entry, on a project that was paused for exceeding ISR writes by 15.5×.
20
+ *
21
+ * A sibling collection removes the self-write entirely. It costs one extra
22
+ * read on the surface that shows the band, and buys immunity.
23
+ *
24
+ * ## Shape
25
+ *
26
+ * priceIndex/{leafId} the current band
27
+ * priceIndex/{leafId}/daily/{YYYYMMDD} one point per day, for a series
28
+ *
29
+ * 🛑 NO `soldPrices: number[]` on the document. `CategoryDocument.metrics`
30
+ * already carries two unbounded arrays and a third is the 1 MiB trap. The
31
+ * samples stay in job memory and only the summary is written.
32
+ */
33
+ import { BaseRepository } from "../../../providers/db-firebase";
34
+ /** Which acquisition path a sale came through. Counts, not amounts. */
35
+ export interface PriceIndexByPath {
36
+ standard?: number;
37
+ auction?: number;
38
+ offer?: number;
39
+ }
40
+ export interface PriceIndexDocument {
41
+ /** The LEAF category id. A sale rolls up to exactly one leaf. */
42
+ id: string;
43
+ currency: "INR";
44
+ /**
45
+ * Scoped by listing type WITHIN the leaf.
46
+ *
47
+ * 🛑 Load-bearing. A named-model leaf holds every listing type for that
48
+ * model — the bey, its stickers, its spare parts, a used one as a classified
49
+ * — because `listingType` and `categorySlugs` are independent axes. A ₹399
50
+ * sticker sheet and a ₹1,299 bey under one leaf would blend into a
51
+ * meaningless band, so the band is per type and the UI shows the one the
52
+ * seller is listing.
53
+ */
54
+ byListingType: Record<string, PriceIndexBand>;
55
+ /** How many days of completed sales the bands cover. */
56
+ windowDays: number;
57
+ computedAt: Date;
58
+ }
59
+ export interface PriceIndexBand {
60
+ min: number;
61
+ median: number;
62
+ max: number;
63
+ /**
64
+ * 🛑 The confidence signal, and the UI must render a small one as a sample
65
+ * size with NO headline number — the same discipline as `truncated`
66
+ * rendering "50+" rather than a false total. A median of two sales is a coin
67
+ * flip presented as data.
68
+ */
69
+ sampleSize: number;
70
+ byPath?: PriceIndexByPath;
71
+ lastSold?: {
72
+ amount: number;
73
+ at: Date;
74
+ via?: "standard" | "auction" | "offer";
75
+ };
76
+ }
77
+ /** One day's point, for a series. */
78
+ export interface PriceIndexDailyPoint {
79
+ id: string;
80
+ /** `YYYYMMDD`, so the document id sorts chronologically on its own. */
81
+ date: string;
82
+ byListingType: Record<string, Pick<PriceIndexBand, "median" | "sampleSize">>;
83
+ computedAt: Date;
84
+ }
85
+ export declare const PRICE_INDEX_COLLECTION = "priceIndex";
86
+ /**
87
+ * Below this, a band renders as a sample size and no headline figure.
88
+ *
89
+ * Exported because the renderer and the job must agree: a threshold the job
90
+ * applied and the UI did not would hide bands that exist, and the reverse
91
+ * would publish a median of one sale as a market rate.
92
+ */
93
+ export declare const PRICE_INDEX_MIN_CONFIDENT_SAMPLES = 3;
94
+ export declare class PriceIndexRepository extends BaseRepository<PriceIndexDocument> {
95
+ constructor();
96
+ getForLeaf(leafId: string): Promise<PriceIndexDocument | null>;
97
+ /**
98
+ * Write the current band for one leaf.
99
+ *
100
+ * `createWithId`/`update` rather than a blind `set`, matching every other
101
+ * rollup repository here — and `update` merges, so a leaf whose band for one
102
+ * listing type disappeared this run would keep the stale entry. Hence
103
+ * `byListingType` is replaced wholesale by passing the full map.
104
+ */
105
+ setForLeaf(leafId: string, data: Pick<PriceIndexDocument, "byListingType" | "windowDays">): Promise<void>;
106
+ /**
107
+ * Append today's point.
108
+ *
109
+ * Keyed on the date, so a re-run on the same day OVERWRITES rather than
110
+ * double-counting — the same idempotency `pageViewRollup`'s `throughDate`
111
+ * buys, achieved here by the document id instead of a cursor.
112
+ */
113
+ setDailyPoint(leafId: string, date: string, byListingType: PriceIndexDailyPoint["byListingType"]): Promise<void>;
114
+ }
115
+ export declare const priceIndexRepository: PriceIndexRepository;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Price Index Repository — what things actually SOLD for here, per leaf.
3
+ *
4
+ * ## 🛑 Why this is its own collection and not a field on `categories`
5
+ *
6
+ * The obvious design is `categories/{leafId}.productDefaults.priceGuidance.sold`,
7
+ * beside the seed-owned `market` band it is shown next to. That design is a
8
+ * runaway-trigger hazard and it is not a theoretical one.
9
+ *
10
+ * `onCategoryWrite` is a `documentWritten` trigger on `categories`, so a job
11
+ * writing a price band there re-triggers it once per document. That is exactly
12
+ * the shape of Root Cause #92, where `onShipmentHeaderWrite` wrote back to the
13
+ * collection it watched and produced **1,017,548 invocations in 24 hours**
14
+ * against a 2M/month allowance, blocked `firebase deploy` with a billing
15
+ * denial, and degraded production.
16
+ *
17
+ * It is also worse than that here, because `notifyRevalidate` fires on every
18
+ * category write — so each nightly price write would drop that category page's
19
+ * ISR entry, on a project that was paused for exceeding ISR writes by 15.5×.
20
+ *
21
+ * A sibling collection removes the self-write entirely. It costs one extra
22
+ * read on the surface that shows the band, and buys immunity.
23
+ *
24
+ * ## Shape
25
+ *
26
+ * priceIndex/{leafId} the current band
27
+ * priceIndex/{leafId}/daily/{YYYYMMDD} one point per day, for a series
28
+ *
29
+ * 🛑 NO `soldPrices: number[]` on the document. `CategoryDocument.metrics`
30
+ * already carries two unbounded arrays and a third is the 1 MiB trap. The
31
+ * samples stay in job memory and only the summary is written.
32
+ */
33
+ import { BaseRepository } from "../../../providers/db-firebase";
34
+ export const PRICE_INDEX_COLLECTION = "priceIndex";
35
+ const DAILY_SUBCOLLECTION = "daily";
36
+ /**
37
+ * Below this, a band renders as a sample size and no headline figure.
38
+ *
39
+ * Exported because the renderer and the job must agree: a threshold the job
40
+ * applied and the UI did not would hide bands that exist, and the reverse
41
+ * would publish a median of one sale as a market rate.
42
+ */
43
+ export const PRICE_INDEX_MIN_CONFIDENT_SAMPLES = 3;
44
+ export class PriceIndexRepository extends BaseRepository {
45
+ constructor() {
46
+ super(PRICE_INDEX_COLLECTION);
47
+ }
48
+ async getForLeaf(leafId) {
49
+ return this.findById(leafId);
50
+ }
51
+ /**
52
+ * Write the current band for one leaf.
53
+ *
54
+ * `createWithId`/`update` rather than a blind `set`, matching every other
55
+ * rollup repository here — and `update` merges, so a leaf whose band for one
56
+ * listing type disappeared this run would keep the stale entry. Hence
57
+ * `byListingType` is replaced wholesale by passing the full map.
58
+ */
59
+ async setForLeaf(leafId, data) {
60
+ const payload = { currency: "INR", ...data, computedAt: new Date() };
61
+ const existing = await this.findById(leafId);
62
+ if (existing) {
63
+ await this.update(leafId, payload);
64
+ }
65
+ else {
66
+ await this.createWithId(leafId, payload);
67
+ }
68
+ }
69
+ /**
70
+ * Append today's point.
71
+ *
72
+ * Keyed on the date, so a re-run on the same day OVERWRITES rather than
73
+ * double-counting — the same idempotency `pageViewRollup`'s `throughDate`
74
+ * buys, achieved here by the document id instead of a cursor.
75
+ */
76
+ async setDailyPoint(leafId, date, byListingType) {
77
+ await this.db
78
+ .collection(PRICE_INDEX_COLLECTION)
79
+ .doc(leafId)
80
+ .collection(DAILY_SUBCOLLECTION)
81
+ .doc(date)
82
+ .set({ date, byListingType, computedAt: new Date() }, { merge: true });
83
+ }
84
+ }
85
+ export const priceIndexRepository = new PriceIndexRepository();
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Category-owned content — description templates and product defaults.
3
+ *
4
+ * ## Why this lives on the CATEGORY and not on a listing template
5
+ *
6
+ * `ListingTemplateDocument` is store-scoped. The ask is that the description
7
+ * lives with the *category*, and the crawl proves the model: worldhobbyshop
8
+ * reuses TWO bodies across 1,195 listings, and tcgindia generates five
9
+ * templated FAQs per product. Nobody writes 1,195 descriptions by hand — our
10
+ * sellers do, because no category→description mechanism exists anywhere in
11
+ * this codebase.
12
+ *
13
+ * ## Why it costs ZERO extra reads
14
+ *
15
+ * Both hot paths already hold the category document: `deriveTaxonomy` does
16
+ * `cats.doc(leaf).get()`, and `CategoryDetailPageView` does `getCategoryBySlug`
17
+ * plus seven parallel reads — already at Rule #6's ~3-round-trip ceiling. A
18
+ * sidecar collection would add an eighth read to the second-most-crawled page
19
+ * on the site.
20
+ *
21
+ * ## Why three fields rather than one blob
22
+ *
23
+ * Each has exactly one reader: `contentBody` → the category page;
24
+ * `descriptionTemplates` → the seller form plus the narrow template endpoint;
25
+ * `productDefaults` → `deriveTaxonomy`. A single `content` object would make
26
+ * every one of those read the other two.
27
+ */
28
+ import type { ProductAuthenticity } from "../../products/schemas/firestore";
29
+ /**
30
+ * Which condition a template is written for.
31
+ *
32
+ * 🛑 A KEYED SET, not one body, because worldhobbyshop demonstrably reuses
33
+ * **two** per category — a pre-owned body and a new-in-box body — and a single
34
+ * string field cannot express that. The variants are the conditions that
35
+ * change what the copy must SAY, not every `CONDITION_OPTIONS` value: a
36
+ * "pre-owned" and a "used" listing want the same disclosure paragraph.
37
+ */
38
+ export type CategoryTemplateVariant = "default" | "new_in_box" | "new_in_packet" | "pre_owned" | "replica";
39
+ export interface CategoryDescriptionTemplate {
40
+ id: string;
41
+ /** What the picker shows, e.g. "Pre-owned — condition as pictured". */
42
+ label: string;
43
+ variant: CategoryTemplateVariant;
44
+ /** `{{placeholder}}` slots; HTML permitted, rendered via RichTextRenderer. */
45
+ body: string;
46
+ /**
47
+ * Slot names found in `body`, derived at write time as a convenience.
48
+ *
49
+ * 🛑 ABSENT means "re-parse", never "this body has no slots". A reader that
50
+ * treats `undefined` as an empty list will skip interpolation entirely on
51
+ * every template written before this field existed — and the failure is a
52
+ * published description containing a literal `{{series}}`, which is the most
53
+ * visible way this feature can break. Use `extractPlaceholders(body)` when
54
+ * it is absent.
55
+ */
56
+ placeholders?: string[];
57
+ /**
58
+ * An admin has published a real listing from this template.
59
+ *
60
+ * Exists because of raikages, the crawl's counter-example: 114-char bodies
61
+ * lifted from the Beyblade Wiki, one of which ships with the literal UI
62
+ * string "Add to Wishlist Add to Wishlist" welded into the product body. An
63
+ * unreviewed template is how that happens, so the flag records review rather
64
+ * than assuming it.
65
+ */
66
+ isTested?: boolean;
67
+ version?: number;
68
+ }
69
+ /** A spec row as the product page renders it. */
70
+ export interface CategorySpecification {
71
+ name: string;
72
+ value: string;
73
+ unit?: string;
74
+ }
75
+ /**
76
+ * What a category hands down to a product filed under it.
77
+ *
78
+ * Three behaviours, and the distinction matters more than the field list:
79
+ *
80
+ * WRITTEN tax, specifications, inTheBox, features — written server-side
81
+ * by `deriveTaxonomy` when the product omits them.
82
+ * FILL-BLANK seo* — fills only an empty field, never overwrites.
83
+ * ADVISORY priceGuidance — NEVER written to `price`. It renders as a hint
84
+ * beside the price input and nothing else.
85
+ */
86
+ export interface CategoryProductDefaults {
87
+ /**
88
+ * Points at a `taxCodes` row. `deriveTaxonomy` resolves it and writes
89
+ * `gstRate`/`hsnCode` onto the product; the order item then snapshots from
90
+ * the product. That layering is what stops a tax-code edit retroactively
91
+ * re-rating an invoice already issued.
92
+ */
93
+ taxCodeId?: string;
94
+ specifications?: CategorySpecification[];
95
+ /**
96
+ * Feature ids merged onto the product, never replacing what it already has.
97
+ * (The old `defaultTags` name went with `ProductDocument.tags`.)
98
+ */
99
+ defaultFeatures?: string[];
100
+ defaultCondition?: string;
101
+ defaultAuthenticity?: ProductAuthenticity;
102
+ /** "What's in the box?" — becomes `ProductDocument.features[]` prose. */
103
+ inTheBox?: string[];
104
+ priceGuidance?: CategoryPriceGuidance;
105
+ seoTitleTemplate?: string;
106
+ seoDescriptionTemplate?: string;
107
+ /** Merged with whatever the product already has. */
108
+ seoKeywords?: string[];
109
+ weightG?: number;
110
+ dimensionsCm?: {
111
+ l: number;
112
+ w: number;
113
+ h: number;
114
+ };
115
+ shippingPaidBy?: string;
116
+ fragile?: boolean;
117
+ /**
118
+ * Rendered at READ time, so fixing a wrong answer fixes every listing at
119
+ * once. The tcgindia model — five per product, three interpolating the
120
+ * name — and nobody else in the crawl of fifteen does it.
121
+ */
122
+ faqTemplates?: {
123
+ question: string;
124
+ answer: string;
125
+ }[];
126
+ }
127
+ /**
128
+ * Two price views, never blended.
129
+ *
130
+ * 🛑 `market` and `sold` are rendered SIDE BY SIDE — "Market ₹799–₹1,499 ·
131
+ * Sold here ₹850–₹1,200 (7 sales)" — and the gap between them is the most
132
+ * useful thing we can tell a seller, given 76–93% of competitor listings never
133
+ * sell at their ask. Averaging the two would launder an inference into a
134
+ * measurement.
135
+ */
136
+ export interface CategoryPriceGuidance {
137
+ currency: "INR";
138
+ /**
139
+ * Day-1 baseline from the competitor crawl: a TYPICAL MARKET LISTING PRICE.
140
+ *
141
+ * 🛑 NOT evidence of a transaction. A competitor's sold-out row proves the
142
+ * item is GONE, not that it sold at that price. Seed-owned — the rollup job
143
+ * must never write here, or a quiet night would erase the baseline.
144
+ */
145
+ market?: {
146
+ /**
147
+ * The one figure every measured band has. Required for exactly that
148
+ * reason — a band with no central value is not a band.
149
+ */
150
+ median: number;
151
+ /**
152
+ * 🛑 OPTIONAL, and that is load-bearing. The crawl recorded quartiles for
153
+ * parts, launchers, TCG and figures and only a MEDIAN AND A RANGE for Hot
154
+ * Wheels (n=7,418, ₹99–36,500). Requiring p25/p75 would have forced a
155
+ * range to be retyped as quartiles, which is a different statistic
156
+ * presented as the one the field is named after. Absent means "not
157
+ * measured", and the hint renders as "around ₹850" rather than a span.
158
+ */
159
+ p25?: number;
160
+ p75?: number;
161
+ /**
162
+ * 🛑 Also optional, for the same reason: the crawl's Beyblade-top table
163
+ * carries no per-leaf sample size at all, and the one place two sources
164
+ * disagreed (HMS: beybladeshopindia n=39 against worldhobbyshop n=2) is
165
+ * recorded precisely because an n=2 median is a coin flip presented as
166
+ * data. Absent must render as absent — never as a confident figure, and
167
+ * never as a number somebody filled in to satisfy the type.
168
+ */
169
+ sampleSize?: number;
170
+ asOf: Date;
171
+ soldOutShare?: number;
172
+ /**
173
+ * Which crawl this came from, and any caveat about it — "worldhobbyshop",
174
+ * "raikages n=68 (the only component-level pricing anywhere)",
175
+ * "toycollectorsindia 1:64 POOLED across mainline and premium".
176
+ *
177
+ * Carried so the question "where did this number come from" is answerable
178
+ * from the data rather than from a markdown file nobody has open, and so a
179
+ * source-priority decision can be applied without re-deriving the bands.
180
+ */
181
+ source?: string;
182
+ };
183
+ /**
184
+ * OUR completed sales only, including won auctions and accepted offers.
185
+ *
186
+ * Absent until there are some — **never zeroed**. A zero would render as a
187
+ * real figure meaning "things sell here for ₹0", which is worse than silence.
188
+ */
189
+ sold?: {
190
+ min: number;
191
+ median: number;
192
+ max: number;
193
+ sampleSize: number;
194
+ windowDays: number;
195
+ byPath?: {
196
+ standard?: number;
197
+ auction?: number;
198
+ offer?: number;
199
+ };
200
+ };
201
+ lastSold?: {
202
+ amount: number;
203
+ at: Date;
204
+ via?: "standard" | "auction" | "offer";
205
+ };
206
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Category-owned content — description templates and product defaults.
3
+ *
4
+ * ## Why this lives on the CATEGORY and not on a listing template
5
+ *
6
+ * `ListingTemplateDocument` is store-scoped. The ask is that the description
7
+ * lives with the *category*, and the crawl proves the model: worldhobbyshop
8
+ * reuses TWO bodies across 1,195 listings, and tcgindia generates five
9
+ * templated FAQs per product. Nobody writes 1,195 descriptions by hand — our
10
+ * sellers do, because no category→description mechanism exists anywhere in
11
+ * this codebase.
12
+ *
13
+ * ## Why it costs ZERO extra reads
14
+ *
15
+ * Both hot paths already hold the category document: `deriveTaxonomy` does
16
+ * `cats.doc(leaf).get()`, and `CategoryDetailPageView` does `getCategoryBySlug`
17
+ * plus seven parallel reads — already at Rule #6's ~3-round-trip ceiling. A
18
+ * sidecar collection would add an eighth read to the second-most-crawled page
19
+ * on the site.
20
+ *
21
+ * ## Why three fields rather than one blob
22
+ *
23
+ * Each has exactly one reader: `contentBody` → the category page;
24
+ * `descriptionTemplates` → the seller form plus the narrow template endpoint;
25
+ * `productDefaults` → `deriveTaxonomy`. A single `content` object would make
26
+ * every one of those read the other two.
27
+ */
28
+ export {};
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import type { CategoryAncestor, CategoryMetrics, CategoryDisplay } from "../types";
8
8
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
9
+ import type { CategoryDescriptionTemplate, CategoryProductDefaults } from "./category-content";
10
+ export type { CategoryDescriptionTemplate, CategoryProductDefaults, CategoryPriceGuidance, CategorySpecification, CategoryTemplateVariant, } from "./category-content";
9
11
  export type { CategoryAncestor, CategoryMetrics };
10
12
  /**
11
13
  * How a bundle resolves its member products.
@@ -82,9 +84,9 @@ export interface CategoryDocument extends BaseDocument {
82
84
  name: string;
83
85
  slug: string;
84
86
  description?: string;
85
- /** Tester sandbox flag — set on shared admin-seeded test categories/brands/bundles; swept by testerSandboxCleanup. */
87
+ /** Test-data flag — set on shared admin-seeded test categories/brands/bundles; swept by testDataCleanup. */
86
88
  isTestData?: boolean;
87
- /** When isTestData, the cutoff after which testerSandboxCleanup deletes this doc. */
89
+ /** When isTestData, the cutoff after which testDataCleanup deletes this doc. */
88
90
  testDataExpiresAt?: Date;
89
91
  rootId: string;
90
92
  parentIds: string[];
@@ -135,6 +137,38 @@ export interface CategoryDocument extends BaseDocument {
135
137
  question: string;
136
138
  answer: string;
137
139
  }[];
140
+ /**
141
+ * Alternate names this category is searched by — indexed into `searchTxt`
142
+ * and rendered nowhere.
143
+ *
144
+ * B5. A named-model leaf is the case that needs it: *Lost Longinus* is sold
145
+ * in the west as *Lost Luinor L2* and its product code is *B-66*, and a
146
+ * buyer types whichever one they learned first. `name` can only hold one of
147
+ * the three.
148
+ *
149
+ * 🛑 Watch the 600-token ceiling in `buildSearchTxt`, which truncates
150
+ * SILENTLY. A category's own tokens (name, slug, description, path, every
151
+ * ancestor name) come first in the array, so an unbounded alias list would
152
+ * evict the lineage tokens that are the whole reason ancestors are indexed.
153
+ * The generator caps a model leaf at the Hasbro name plus the product code.
154
+ */
155
+ aliases?: string[];
156
+ /**
157
+ * 400–900 chars of keyword-rich HTML with sibling/parent cross-links,
158
+ * rendered via `<RichTextRenderer>`.
159
+ *
160
+ * 🛑 DISTINCT from `description`, which is a <=230-char blurb that also
161
+ * feeds `seo.description` and every card. Conflating them would put a
162
+ * 900-char body in a card and a 230-char stub on the page.
163
+ *
164
+ * 36 of worldhobbyshop's 46 categories carry one and it is their actual
165
+ * ranking mechanism; no Shopify competitor in the crawl has any.
166
+ */
167
+ contentBody?: string;
168
+ /** Condition-keyed description bodies. A SET, because WHS reuses two per category. */
169
+ descriptionTemplates?: CategoryDescriptionTemplate[];
170
+ /** What this category hands down to a product filed under it. */
171
+ productDefaults?: CategoryProductDefaults;
138
172
  /**
139
173
  * "special" — admin-curated bundle; writes reverse `partOfBundleIds`
140
174
  * pointers on member products and shows the "Bundled" badge on cards.
@@ -194,6 +228,19 @@ export interface CategoryDocument extends BaseDocument {
194
228
  isActive: boolean;
195
229
  isSearchable: boolean;
196
230
  showOnHomepage?: boolean;
231
+ /**
232
+ * Prefix-expanded search tokens, derived on every write by
233
+ * `CategoriesRepository.buildSearchTxtFor` → `buildCategorySearchTxt`.
234
+ *
235
+ * 🛑 Distinct from `isSearchable`, which is an admin visibility flag. This is
236
+ * the index that makes a category findable AT ALL: before it existed,
237
+ * `CATEGORY_SEARCH_SCAN_LIMIT` was capped at 100 by `SIEVE_DEFAULTS.maxPageSize`
238
+ * — so raising that number did nothing — and a tier-4 model like
239
+ * "Lost Longinus" was unfindable in every picker.
240
+ *
241
+ * Never fed any PII: no `createdBy` (a raw uid) and no `createdByStoreName`.
242
+ */
243
+ searchTxt?: string[];
197
244
  createdBy: string;
198
245
  /** Whether this category was created by admin or a store owner. */
199
246
  createdByType?: "admin" | "store";
@@ -204,12 +251,12 @@ export interface CategoryDocument extends BaseDocument {
204
251
  ancestors: CategoryAncestor[];
205
252
  }
206
253
  export declare const CATEGORIES_COLLECTION: "categories";
207
- export declare const CATEGORIES_INDEXED_FIELDS: readonly ["slug", "rootId", "tier", "parentIds", "isLeaf", "isFeatured", "featuredPriority", "isBrand", "categoryType", "isActive", "isSearchable", "showOnHomepage", "createdBy", "createdByType", "createdByStoreId", "createdAt"];
254
+ export declare const CATEGORIES_INDEXED_FIELDS: readonly ["slug", "rootId", "tier", "parentIds", "isLeaf", "isFeatured", "featuredPriority", "isBrand", "categoryType", "isActive", "isSearchable", "showOnHomepage", "searchTxt", "createdBy", "createdByType", "createdByStoreId", "createdAt"];
208
255
  export declare const MIN_ITEMS_FOR_FEATURED: 8;
209
256
  export declare const MAX_FEATURED_CATEGORIES: 4;
210
257
  export declare const DEFAULT_CATEGORY_DATA: Partial<CategoryDocument>;
211
- export declare const CATEGORIES_PUBLIC_FIELDS: readonly ["id", "name", "slug", "description", "rootId", "parentIds", "childrenIds", "tier", "path", "order", "position", "subtreeSize", "isLeaf", "metrics.totalProductCount", "metrics.totalAuctionCount", "metrics.totalItemCount", "isFeatured", "featuredPriority", "isBrand", "seo", "display", "isActive", "ancestors"];
212
- export declare const CATEGORIES_UPDATABLE_FIELDS: readonly ["name", "slug", "description", "order", "isFeatured", "featuredPriority", "isBrand", "seo", "display", "isActive", "isSearchable"];
258
+ export declare const CATEGORIES_PUBLIC_FIELDS: readonly ["id", "name", "slug", "description", "rootId", "parentIds", "childrenIds", "tier", "path", "order", "position", "subtreeSize", "isLeaf", "metrics.totalProductCount", "metrics.totalAuctionCount", "metrics.totalItemCount", "isFeatured", "featuredPriority", "isBrand", "seo", "display", "isActive", "ancestors", "highlights", "faqs", "contentBody", "descriptionTemplates", "productDefaults", "aliases"];
259
+ export declare const CATEGORIES_UPDATABLE_FIELDS: readonly ["name", "slug", "description", "order", "isFeatured", "featuredPriority", "isBrand", "seo", "display", "isActive", "isSearchable", "highlights", "faqs", "contentBody", "descriptionTemplates", "productDefaults", "aliases"];
213
260
  export type CategoryCreateInput = Omit<CategoryDocument, "id" | "createdAt" | "updatedAt" | "metrics" | "isLeaf" | "ancestors"> & {
214
261
  parentIds?: string[];
215
262
  parentId?: string | null;
@@ -285,6 +332,11 @@ export declare const CATEGORY_FIELDS: {
285
332
  readonly VIEW_COUNT: "viewCount";
286
333
  readonly IS_ACTIVE: "isActive";
287
334
  readonly IS_SEARCHABLE: "isSearchable";
335
+ readonly SEARCH_TXT: "searchTxt";
336
+ readonly CONTENT_BODY: "contentBody";
337
+ readonly DESCRIPTION_TEMPLATES: "descriptionTemplates";
338
+ readonly PRODUCT_DEFAULTS: "productDefaults";
339
+ readonly PRODUCT_DEFAULTS_TAX_CODE_ID: "productDefaults.taxCodeId";
288
340
  readonly CATEGORY_TYPE: "categoryType";
289
341
  readonly CREATED_BY: "createdBy";
290
342
  readonly CREATED_AT: "createdAt";
@@ -293,5 +345,22 @@ export declare const CATEGORY_FIELDS: {
293
345
  };
294
346
  /**
295
347
  * Build a category tree from a flat list of CategoryDocument objects.
348
+ *
349
+ * 🛑 ONE INDEXING PASS, then recursion — not a `.filter()` per node.
350
+ * This used to scan the whole array once per category to find its children,
351
+ * which is O(n²): 2,209 comparisons at today's 47 nodes, ~109,000 at the ~330
352
+ * this plan seeds, and **2.25 million** at 1,500. It runs inside
353
+ * `categoriesRepository.buildTree()`, i.e. on a request path. Grouping by
354
+ * parent id first makes it O(n log n) (the sorts) and the output is
355
+ * byte-identical: same roots, same child order, same depths.
356
+ *
357
+ * 🛑 The `seen` set is not defensive padding. A root is picked by
358
+ * `tier === 0`, and nothing stops a corrupt row from being tier 0 *and* a
359
+ * descendant of its own child (`parentIds` is a denormalised chain that four
360
+ * different write paths maintain). The old code would recurse forever on that,
361
+ * and a hung SSR render is worse than a failed one — it holds a function open
362
+ * for its whole timeout and reports nothing. A revisited node is dropped from
363
+ * the second position it appears in rather than throwing: a malformed edge
364
+ * should cost one tile, not the category page.
296
365
  */
297
366
  export declare function buildCategoryTree(categories: CategoryDocument[], rootId?: string): CategoryTreeNode[];