@mohasinac/appkit 4.42.18 → 4.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/dist/_internal/client/features/lottery/LotteryDetailView.js +22 -1
  2. package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
  3. package/dist/_internal/server/features/categories/adapters.js +235 -0
  4. package/dist/_internal/server/features/checkout/actions.js +161 -22
  5. package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
  6. package/dist/_internal/server/features/orders/adapters.js +21 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +24 -0
  8. package/dist/_internal/server/features/products/list-public.js +10 -2
  9. package/dist/_internal/server/features/refunds/actions.js +1 -1
  10. package/dist/_internal/server/features/seo/sitemap.js +54 -15
  11. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/stores/adapters.js +6 -0
  13. package/dist/_internal/server/functions/scheduled.d.ts +4 -2
  14. package/dist/_internal/server/functions/scheduled.js +64 -6
  15. package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
  16. package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
  17. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
  18. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
  19. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
  20. package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
  21. package/dist/_internal/server/jobs/core/onCategoryWrite.js +59 -0
  22. package/dist/_internal/server/jobs/core/onProductWrite.js +31 -0
  23. package/dist/_internal/server/jobs/core/onReviewWrite.js +8 -0
  24. package/dist/_internal/server/jobs/core/onStoreWrite.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/onStoreWrite.js +8 -2
  26. package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
  27. package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
  28. package/dist/_internal/server/jobs/core/revalidateNotify.d.ts +28 -0
  29. package/dist/_internal/server/jobs/core/revalidateNotify.js +125 -0
  30. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
  31. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
  32. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
  33. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
  34. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
  35. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
  36. package/dist/_internal/server/jobs/handlers/index.js +3 -1
  37. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
  38. package/dist/_internal/server/jobs/handlers/messages.js +5 -2
  39. package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
  40. package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
  41. package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
  42. package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
  43. package/dist/_internal/shared/actions/action-registry.js +34 -0
  44. package/dist/_internal/shared/cache/revalidate-targets.d.ts +49 -0
  45. package/dist/_internal/shared/cache/revalidate-targets.js +157 -0
  46. package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
  47. package/dist/_internal/shared/checkout/order-math.js +194 -7
  48. package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
  49. package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
  50. package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
  51. package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
  52. package/dist/_internal/shared/fees/calculator.d.ts +105 -0
  53. package/dist/_internal/shared/fees/calculator.js +143 -0
  54. package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
  55. package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
  56. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
  57. package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
  58. package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
  59. package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
  60. package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
  61. package/dist/_internal/shared/templating/placeholders.js +73 -0
  62. package/dist/client.d.ts +2 -0
  63. package/dist/client.js +1 -0
  64. package/dist/constants/api-endpoints.d.ts +42 -0
  65. package/dist/constants/api-endpoints.js +14 -0
  66. package/dist/features/admin/actions/admin-actions.d.ts +1 -1
  67. package/dist/features/admin/actions/admin-actions.js +1 -1
  68. package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
  69. package/dist/features/admin/components/AdminProductEditorView.js +8 -1
  70. package/dist/features/admin/components/AdminProductsView.js +78 -7
  71. package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
  72. package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
  73. package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
  74. package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
  75. package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
  76. package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
  77. package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
  78. package/dist/features/auctions/components/AuctionBottomActions.js +10 -1
  79. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  80. package/dist/features/auctions/components/AuctionLiveStatusBadge.d.ts +20 -0
  81. package/dist/features/auctions/components/AuctionLiveStatusBadge.js +21 -0
  82. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
  83. package/dist/features/auctions/components/PlaceBidFormClient.js +20 -2
  84. package/dist/features/auctions/hooks/useIsAuctionEnded.d.ts +41 -0
  85. package/dist/features/auctions/hooks/useIsAuctionEnded.js +76 -0
  86. package/dist/features/auth/schemas/firestore.d.ts +0 -1
  87. package/dist/features/blog/api/route.js +16 -1
  88. package/dist/features/blog/schemas/firestore.d.ts +2 -2
  89. package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
  90. package/dist/features/categories/actions/category-actions.d.ts +2 -2
  91. package/dist/features/categories/actions/category-actions.js +22 -10
  92. package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
  93. package/dist/features/categories/api/[id]/description-template/route.js +252 -0
  94. package/dist/features/categories/api/route.js +156 -11
  95. package/dist/features/categories/components/CategoriesIndexListing.d.ts +1 -3
  96. package/dist/features/categories/components/CategoriesIndexListing.js +1 -1
  97. package/dist/features/categories/components/CategoriesIndexPageView.d.ts +27 -1
  98. package/dist/features/categories/components/CategoriesIndexPageView.js +24 -33
  99. package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
  100. package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
  101. package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
  102. package/dist/features/categories/constants/listing-categories.d.ts +17 -0
  103. package/dist/features/categories/constants/listing-categories.js +53 -0
  104. package/dist/features/categories/hooks/useCategoryTree.js +28 -2
  105. package/dist/features/categories/repository/categories.repository.d.ts +73 -12
  106. package/dist/features/categories/repository/categories.repository.js +203 -42
  107. package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
  108. package/dist/features/categories/repository/price-index.repository.js +85 -0
  109. package/dist/features/categories/schemas/category-content.d.ts +206 -0
  110. package/dist/features/categories/schemas/category-content.js +28 -0
  111. package/dist/features/categories/schemas/firestore.d.ts +74 -5
  112. package/dist/features/categories/schemas/firestore.js +74 -3
  113. package/dist/features/categories/schemas/index.d.ts +1047 -0
  114. package/dist/features/categories/schemas/index.js +96 -0
  115. package/dist/features/categories/server.d.ts +1 -0
  116. package/dist/features/categories/server.js +7 -0
  117. package/dist/features/categories/types/index.d.ts +9 -0
  118. package/dist/features/events/schemas/firestore.d.ts +2 -2
  119. package/dist/features/faq/schemas/firestore.d.ts +2 -1
  120. package/dist/features/faq/schemas/firestore.js +20 -8
  121. package/dist/features/faq/utils/getHomepageFaqsCached.d.ts +20 -0
  122. package/dist/features/faq/utils/getHomepageFaqsCached.js +22 -0
  123. package/dist/features/homepage/components/MarketplaceHomepageView.js +13 -3
  124. package/dist/features/homepage/lib/live-stats.js +18 -8
  125. package/dist/features/media/MediaImage.js +19 -2
  126. package/dist/features/orders/schemas/firestore.d.ts +70 -0
  127. package/dist/features/orders/types/index.d.ts +55 -0
  128. package/dist/features/products/actions/product-actions.d.ts +0 -9
  129. package/dist/features/products/actions/product-actions.js +25 -0
  130. package/dist/features/products/columns/index.d.ts +0 -1
  131. package/dist/features/products/columns/index.js +5 -1
  132. package/dist/features/products/components/ArtStickersListView.js +7 -1
  133. package/dist/features/products/components/PrizeDrawsSection.js +5 -0
  134. package/dist/features/products/components/ProductFilters.d.ts +0 -9
  135. package/dist/features/products/components/ProductForm.d.ts +27 -1
  136. package/dist/features/products/components/ProductForm.js +26 -12
  137. package/dist/features/products/components/ProductsIndexPageView.js +1 -1
  138. package/dist/features/products/constants/sieve.d.ts +0 -6
  139. package/dist/features/products/constants/sieve.js +35 -2
  140. package/dist/features/products/repository/products.repository.d.ts +115 -1
  141. package/dist/features/products/repository/products.repository.js +491 -39
  142. package/dist/features/products/schemas/firestore.d.ts +65 -5
  143. package/dist/features/products/schemas/firestore.js +9 -0
  144. package/dist/features/products/schemas/product-features.d.ts +140 -2
  145. package/dist/features/products/schemas/product-features.js +62 -1
  146. package/dist/features/promotions/repository/coupons.repository.js +6 -1
  147. package/dist/features/promotions/schemas/firestore.d.ts +44 -1
  148. package/dist/features/promotions/schemas/firestore.js +76 -2
  149. package/dist/features/reviews/repository/reviews.repository.d.ts +22 -0
  150. package/dist/features/reviews/repository/reviews.repository.js +35 -0
  151. package/dist/features/seller/actions/seller-actions.js +17 -2
  152. package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
  153. package/dist/features/seller/components/PrintCenterView.js +3 -2
  154. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
  155. package/dist/features/seller/components/SellerOrdersView.js +6 -1
  156. package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
  157. package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
  158. package/dist/features/seller/components/SellerProductShell.js +101 -9
  159. package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
  160. package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
  161. package/dist/features/seller/components/SellerProductsView.js +108 -3
  162. package/dist/features/seller/types/index.d.ts +17 -0
  163. package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
  164. package/dist/features/seller/utils/product-draft-mapping.js +17 -0
  165. package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
  166. package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
  167. package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
  168. package/dist/features/stores/schemas/firestore.d.ts +31 -2
  169. package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
  170. package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
  171. package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
  172. package/dist/features/tax-codes/schemas/firestore.js +82 -0
  173. package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
  174. package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
  175. package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
  176. package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
  177. package/dist/features/tester/actions/index.d.ts +0 -1
  178. package/dist/features/tester/actions/index.js +0 -1
  179. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
  180. package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
  181. package/dist/features/tester/components/index.d.ts +0 -4
  182. package/dist/features/tester/components/index.js +0 -2
  183. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
  184. package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
  185. package/dist/features/tester/schemas/firestore.d.ts +0 -6
  186. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
  187. package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
  188. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
  189. package/dist/features/tester/seed-data/authored/index.js +0 -2
  190. package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
  191. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +10 -29
  192. package/dist/index.d.ts +21 -7
  193. package/dist/index.js +53 -7
  194. package/dist/next/routing/route-map.d.ts +2 -4
  195. package/dist/next/routing/route-map.js +1 -2
  196. package/dist/repositories/index.d.ts +1 -0
  197. package/dist/repositories/index.js +1 -0
  198. package/dist/schemas/registry.d.ts +506 -0
  199. package/dist/seed/_helpers/brand-rows.d.ts +35 -0
  200. package/dist/seed/_helpers/brand-rows.js +576 -0
  201. package/dist/seed/_helpers/category-content.d.ts +39 -0
  202. package/dist/seed/_helpers/category-content.js +634 -0
  203. package/dist/seed/_helpers/category-forest.d.ts +2 -0
  204. package/dist/seed/_helpers/category-forest.js +585 -243
  205. package/dist/seed/_helpers/category-models.d.ts +5 -0
  206. package/dist/seed/_helpers/category-models.js +1364 -0
  207. package/dist/seed/_helpers/category-tree.js +17 -1
  208. package/dist/seed/_helpers/character-features.d.ts +10 -0
  209. package/dist/seed/_helpers/character-features.js +118 -0
  210. package/dist/seed/_helpers/media.d.ts +1 -1
  211. package/dist/seed/_helpers/media.js +44 -8
  212. package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
  213. package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
  214. package/dist/seed/categories-seed-data.js +147 -169
  215. package/dist/seed/index.d.ts +1 -0
  216. package/dist/seed/index.js +1 -0
  217. package/dist/seed/manifest.d.ts +1 -1
  218. package/dist/seed/manifest.js +5 -0
  219. package/dist/seed/orders-seed-data.js +5 -3
  220. package/dist/seed/product-features-seed-data.d.ts +1 -8
  221. package/dist/seed/product-features-seed-data.js +358 -154
  222. package/dist/seed/products-art-seed-data.js +16 -4
  223. package/dist/seed/products-auctions-seed-data.js +6 -6
  224. package/dist/seed/products-classifieds-seed-data.js +17 -7
  225. package/dist/seed/products-digital-codes-seed-data.js +12 -12
  226. package/dist/seed/products-preorders-seed-data.js +8 -8
  227. package/dist/seed/products-prize-draws-seed-data.js +25 -8
  228. package/dist/seed/products-standard-seed-data.js +10 -60
  229. package/dist/seed/products-stickers-seed-data.js +6 -6
  230. package/dist/seed/site-settings-seed-data.js +24 -0
  231. package/dist/seed/store-extensions-seed-data.js +10 -4
  232. package/dist/seed/tax-codes-seed-data.d.ts +34 -0
  233. package/dist/seed/tax-codes-seed-data.js +100 -0
  234. package/dist/seed/test-data-ttl.d.ts +29 -0
  235. package/dist/seed/test-data-ttl.js +31 -0
  236. package/dist/server-entry.d.ts +1 -1
  237. package/dist/server-entry.js +6 -1
  238. package/dist/server.d.ts +4 -1
  239. package/dist/server.js +11 -2
  240. package/dist/styles.css +40 -0
  241. package/dist/ui/components/PaginatedSelect.d.ts +14 -0
  242. package/dist/ui/components/PaginatedSelect.js +0 -0
  243. package/dist/ui/components/PaginatedSelect.style.css +40 -0
  244. package/dist/utils/media-url.d.ts +26 -0
  245. package/dist/utils/media-url.js +144 -0
  246. package/dist/utils/physical-location.d.ts +23 -0
  247. package/dist/utils/physical-location.js +52 -0
  248. package/dist/utils/search-txt-builders.d.ts +20 -0
  249. package/dist/utils/search-txt-builders.js +36 -0
  250. package/package.json +1 -1
  251. package/scripts/seed-cli.mjs +4 -1
  252. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
  253. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
  254. package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
  255. package/dist/features/tester/actions/leaderboard-actions.js +0 -5
  256. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
  257. package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
  258. package/dist/features/tester/components/TesterHubView.d.ts +0 -5
  259. package/dist/features/tester/components/TesterHubView.js +0 -110
  260. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
  261. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
  262. package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
  263. package/dist/features/tester/seed-data/tester-ttl.js +0 -6
@@ -47,6 +47,97 @@ export const bundleQueryRuleSchema = z.discriminatedUnion("type", [
47
47
  limit: z.number().int().positive(),
48
48
  }),
49
49
  ]);
50
+ /*
51
+ * ── Category-owned content (B3), mirrored here in B5 ────────────────────────
52
+ *
53
+ * 🛑 These three were added to `CategoryDocument` in B3 and NOT to this
54
+ * schema, which is the drift this file has a history of: `highlights` and
55
+ * `faqs` sat in the interface with no mirror for months, and nothing enforces
56
+ * parity between the two. Mirroring them is what lets the admin category
57
+ * editor (C2) validate a template body server-side instead of trusting the
58
+ * form.
59
+ */
60
+ export const categoryDescriptionTemplateSchema = z.object({
61
+ id: z.string(),
62
+ label: z.string(),
63
+ variant: z.enum(["default", "new_in_box", "new_in_packet", "pre_owned", "replica"]),
64
+ body: z.string(),
65
+ /** Absent means "re-parse", never "no slots" — see the field's own doc. */
66
+ placeholders: z.array(z.string()).optional(),
67
+ isTested: z.boolean().optional(),
68
+ version: z.number().int().optional(),
69
+ });
70
+ export const categorySpecificationSchema = z.object({
71
+ name: z.string(),
72
+ value: z.string(),
73
+ unit: z.string().optional(),
74
+ });
75
+ export const categoryPriceGuidanceSchema = z.object({
76
+ currency: z.literal("INR"),
77
+ market: z
78
+ .object({
79
+ median: rupeesSchema,
80
+ // Optional on purpose — see the field docs in category-content.ts. The
81
+ // crawl measured quartiles for some verticals and a median-plus-range
82
+ // for others, and a required p25 would force a range to be retyped as a
83
+ // quartile.
84
+ p25: rupeesSchema.optional(),
85
+ p75: rupeesSchema.optional(),
86
+ sampleSize: z.number().int().positive().optional(),
87
+ asOf: firestoreDateSchema,
88
+ soldOutShare: z.number().min(0).max(1).optional(),
89
+ source: z.string().optional(),
90
+ })
91
+ .optional(),
92
+ /*
93
+ * 🛑 No `.default()` anywhere in here. An absent `sold` means "we have no
94
+ * sales yet" and a zeroed one would render as "things sell here for ₹0" —
95
+ * which is worse than silence, and is exactly what a defaulting schema would
96
+ * manufacture on the first quiet night.
97
+ */
98
+ sold: z
99
+ .object({
100
+ min: rupeesSchema,
101
+ median: rupeesSchema,
102
+ max: rupeesSchema,
103
+ sampleSize: z.number().int().positive(),
104
+ windowDays: z.number().int().positive(),
105
+ byPath: z
106
+ .object({
107
+ standard: z.number().int().nonnegative().optional(),
108
+ auction: z.number().int().nonnegative().optional(),
109
+ offer: z.number().int().nonnegative().optional(),
110
+ })
111
+ .optional(),
112
+ })
113
+ .optional(),
114
+ lastSold: z
115
+ .object({
116
+ amount: rupeesSchema,
117
+ at: firestoreDateSchema,
118
+ via: z.enum(["standard", "auction", "offer"]).optional(),
119
+ })
120
+ .optional(),
121
+ });
122
+ export const categoryProductDefaultsSchema = z.object({
123
+ taxCodeId: z.string().optional(),
124
+ specifications: z.array(categorySpecificationSchema).optional(),
125
+ defaultFeatures: z.array(z.string()).optional(),
126
+ defaultCondition: z.string().optional(),
127
+ defaultAuthenticity: z.enum(["original", "reproduction", "unverified"]).optional(),
128
+ inTheBox: z.array(z.string()).optional(),
129
+ priceGuidance: categoryPriceGuidanceSchema.optional(),
130
+ seoTitleTemplate: z.string().optional(),
131
+ seoDescriptionTemplate: z.string().optional(),
132
+ seoKeywords: z.array(z.string()).optional(),
133
+ weightG: z.number().nonnegative().optional(),
134
+ dimensionsCm: z
135
+ .object({ l: z.number().positive(), w: z.number().positive(), h: z.number().positive() })
136
+ .optional(),
137
+ shippingPaidBy: z.string().optional(),
138
+ fragile: z.boolean().optional(),
139
+ faqTemplates: z.array(z.object({ question: z.string(), answer: z.string() })).optional(),
140
+ });
50
141
  export const bundleItemDetailSchema = z.object({
51
142
  productId: z.string(),
52
143
  drawCount: z.number().int().nonnegative().optional(),
@@ -78,6 +169,11 @@ export const categoryFirestoreSchema = z.object({
78
169
  brandFounded: z.number().int().optional(),
79
170
  highlights: z.array(z.string()).optional(),
80
171
  faqs: z.array(z.object({ question: z.string(), answer: z.string() })).optional(),
172
+ aliases: z.array(z.string()).optional(),
173
+ contentBody: z.string().optional(),
174
+ descriptionTemplates: z.array(categoryDescriptionTemplateSchema).optional(),
175
+ productDefaults: categoryProductDefaultsSchema.optional(),
176
+ searchTxt: z.array(z.string()).optional(),
81
177
  bundleKind: z.enum(["special", "brand"]).optional(),
82
178
  brandSlug: z.string().optional(),
83
179
  bundlePrice: rupeesSchema.optional(),
@@ -7,3 +7,4 @@ export * from "./actions";
7
7
  export { CategoriesRepository, categoriesRepository, } from "./repository/categories.repository";
8
8
  export { GET as categoriesGET, GET, POST } from "./api/route";
9
9
  export { categoryItemGET, categoryItemPATCH, categoryItemDELETE, } from "./api/[id]/route";
10
+ export { GET as categoryDescriptionTemplateGET } from "./api/[id]/description-template/route";
@@ -7,3 +7,10 @@ export * from "./actions";
7
7
  export { CategoriesRepository, categoriesRepository, } from "./repository/categories.repository";
8
8
  export { GET as categoriesGET, GET, POST } from "./api/route";
9
9
  export { categoryItemGET, categoryItemPATCH, categoryItemDELETE, } from "./api/[id]/route";
10
+ /*
11
+ * The seller form's template read. A separate route rather than a field on
12
+ * `categoryItemGET` because that one returns the whole document and is not
13
+ * edge-cached — this is a narrow, hour-cacheable answer to one question, hit
14
+ * on every category change in the form.
15
+ */
16
+ export { GET as categoryDescriptionTemplateGET } from "./api/[id]/description-template/route";
@@ -57,6 +57,15 @@ export interface CategoryItem {
57
57
  metrics?: CategoryMetrics;
58
58
  isFeatured?: boolean;
59
59
  featuredPriority?: number;
60
+ /**
61
+ * Whether the category is live. Declared here because `AdminCategoriesView`
62
+ * reads it off THIS route to render its Status column, and its fallback is
63
+ * `toStringValue(item.status, "Active")` — a field `CategoryDocument` does
64
+ * not have. So an absent `isActive` does not render "unknown", it renders
65
+ * **every row as "Active"**, which is the same silent-wrong-value shape the
66
+ * `parentId` comment in that file documents.
67
+ */
68
+ isActive?: boolean;
60
69
  /** @deprecated Use categoryType === "brand" */ isBrand?: boolean;
61
70
  seo?: CategorySeo;
62
71
  display?: CategoryDisplay;
@@ -16,9 +16,9 @@ export interface EventDocument extends BaseDocument {
16
16
  title: string;
17
17
  description: string;
18
18
  status: EventStatus;
19
- /** Tester sandbox flag — set on the shared admin-seeded test event; swept by testerSandboxCleanup. */
19
+ /** Test-data flag — set on the shared admin-seeded test event; swept by testDataCleanup. */
20
20
  isTestData?: boolean;
21
- /** When isTestData, the cutoff after which testerSandboxCleanup deletes this doc. */
21
+ /** When isTestData, the cutoff after which testDataCleanup deletes this doc. */
22
22
  testDataExpiresAt?: Date;
23
23
  startsAt: Date;
24
24
  endsAt: Date;
@@ -4,6 +4,7 @@
4
4
  import type { FAQCategory, FAQAnswer, FAQStats } from "../types";
5
5
  export type { FAQCategory, FAQAnswer, FAQStats } from "../types";
6
6
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
7
+ import { extractPlaceholders } from "../../../_internal/shared/templating/placeholders";
7
8
  export interface FAQSEO {
8
9
  slug: string;
9
10
  metaTitle?: string;
@@ -53,7 +54,7 @@ export declare const faqQueryHelpers: {
53
54
  readonly byTag: (tag: string) => readonly ["tags", "array-contains", string];
54
55
  readonly byCreator: (userId: string) => readonly ["createdBy", "==", string];
55
56
  };
56
- export declare function extractVariablePlaceholders(text: string): string[];
57
+ export declare const extractVariablePlaceholders: typeof extractPlaceholders;
57
58
  export declare function usesVariableInterpolation(faq: FAQDocument): boolean;
58
59
  export declare function validateFAQVariables(faq: FAQDocument, siteSettingsVariables: Record<string, string | number>): string[];
59
60
  export declare function isPopularFAQ(faq: FAQDocument): boolean;
@@ -2,6 +2,8 @@
2
2
  * FAQ Feature Firestore Document Types & Constants
3
3
  */
4
4
  import { generateFAQId } from "../../../utils/id-generators";
5
+ // Imported from the DEFINING module, not a barrel (Root Cause #18).
6
+ import { extractPlaceholders, missingPlaceholders, } from "../../../_internal/shared/templating/placeholders";
5
7
  export const FAQS_COLLECTION = "faqs";
6
8
  export const FAQS_INDEXED_FIELDS = [
7
9
  "slug",
@@ -74,21 +76,31 @@ export const faqQueryHelpers = {
74
76
  byTag: (tag) => ["tags", "array-contains", tag],
75
77
  byCreator: (userId) => ["createdBy", "==", userId],
76
78
  };
77
- export function extractVariablePlaceholders(text) {
78
- const regex = /\{\{(\w+)\}\}/g;
79
- return Array.from(text.matchAll(regex), (m) => m[1]);
80
- }
79
+ /*
80
+ * The `{{placeholder}}` grammar moved to `_internal/shared/templating/
81
+ * placeholders.ts` so category description templates can share it instead of
82
+ * carrying a second regex (Root Cause #75).
83
+ *
84
+ * These three keep their original NAMES and signatures on purpose: they have
85
+ * FAQ-specific behaviour layered on the shared primitive — both gate on
86
+ * `useSiteSettings`, and `validateFAQVariables` merges the FAQ's own
87
+ * `variables` over the site-settings map. That layer is why this is a
88
+ * re-export-with-wrapper rather than a plain re-export, and why no FAQ call
89
+ * site changed.
90
+ */
91
+ export const extractVariablePlaceholders = extractPlaceholders;
81
92
  export function usesVariableInterpolation(faq) {
82
93
  if (!faq.useSiteSettings)
83
94
  return false;
84
- return extractVariablePlaceholders(faq.answer.text).length > 0;
95
+ return extractPlaceholders(faq.answer.text).length > 0;
85
96
  }
86
97
  export function validateFAQVariables(faq, siteSettingsVariables) {
87
98
  if (!faq.useSiteSettings)
88
99
  return [];
89
- const placeholders = extractVariablePlaceholders(faq.answer.text);
90
- const available = { ...siteSettingsVariables, ...(faq.variables || {}) };
91
- return placeholders.filter((v) => !(v in available));
100
+ return missingPlaceholders(faq.answer.text, {
101
+ ...siteSettingsVariables,
102
+ ...(faq.variables || {}),
103
+ });
92
104
  }
93
105
  export function isPopularFAQ(faq) {
94
106
  const { views, helpful, notHelpful } = faq.stats;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * React.cache deduplication — one Firestore read per RSC request tree.
3
+ *
4
+ * 🛑 `getHomepageFAQs()` was called TWICE on every homepage render, from two
5
+ * modules that could not see each other: `src/app/[locale]/page.tsx` builds the
6
+ * FAQ JSON-LD from it, and `MarketplaceHomepageView` renders the FAQ section
7
+ * from it. Two call sites, no shared wrapper, so two real queries — and the
8
+ * markup and the visible content had no structural guarantee of agreeing, which
9
+ * is a requirement Google actually enforces.
10
+ *
11
+ * Caching the NARROWEST shared call rather than either caller is the pattern
12
+ * CLAUDE.md § React.cache discipline prescribes, and it is a straight copy of
13
+ * `getSiteSettingsGlobal` (features/admin/utils/) — which exists for exactly the
14
+ * same reason and is the precedent this follows.
15
+ *
16
+ * Takes no arguments on purpose: `React.cache` memoises per argument list, so a
17
+ * parameterised version would give each distinct call its own entry and dedupe
18
+ * nothing.
19
+ */
20
+ export declare const getHomepageFaqsCached: () => Promise<import("..").FAQDocument[]>;
@@ -0,0 +1,22 @@
1
+ import { cache } from "react";
2
+ import { faqsRepository } from "../repository/faqs.repository";
3
+ /**
4
+ * React.cache deduplication — one Firestore read per RSC request tree.
5
+ *
6
+ * 🛑 `getHomepageFAQs()` was called TWICE on every homepage render, from two
7
+ * modules that could not see each other: `src/app/[locale]/page.tsx` builds the
8
+ * FAQ JSON-LD from it, and `MarketplaceHomepageView` renders the FAQ section
9
+ * from it. Two call sites, no shared wrapper, so two real queries — and the
10
+ * markup and the visible content had no structural guarantee of agreeing, which
11
+ * is a requirement Google actually enforces.
12
+ *
13
+ * Caching the NARROWEST shared call rather than either caller is the pattern
14
+ * CLAUDE.md § React.cache discipline prescribes, and it is a straight copy of
15
+ * `getSiteSettingsGlobal` (features/admin/utils/) — which exists for exactly the
16
+ * same reason and is the precedent this follows.
17
+ *
18
+ * Takes no arguments on purpose: `React.cache` memoises per argument list, so a
19
+ * parameterised version would give each distinct call its own entry and dedupe
20
+ * nothing.
21
+ */
22
+ export const getHomepageFaqsCached = cache(() => faqsRepository.getHomepageFAQs());
@@ -2,7 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
3
3
  import { sortBy } from "@mohasinac/appkit";
4
4
  import { Div, Main } from "../../../ui";
5
- import { carouselRepository, faqsRepository, siteSettingsRepository } from "../../../repositories";
5
+ import { carouselRepository } from "../../../repositories";
6
+ // Both are React.cache wrappers around a single repository call, shared with
7
+ // other readers in the same request tree (the locale layout for settings,
8
+ // page.tsx's FAQ JSON-LD for the FAQs). Importing the repositories directly
9
+ // here is what made each of those a second Firestore read.
10
+ import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal";
11
+ import { getHomepageFaqsCached } from "../../faq/utils/getHomepageFaqsCached";
6
12
  import { safeRead } from "../../../errors/safe-read";
7
13
  import { fetchLiveStats } from "../lib/live-stats";
8
14
  import { renderSection, AnnouncementBar } from "../lib/section-renderer";
@@ -46,7 +52,10 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
46
52
  const slides = await safeRead(() => carouselRepository.getActiveSlides(), {
47
53
  route: "/", key: "homepage.carouselSlides", fallback: [],
48
54
  });
49
- const siteSettings = await safeRead(() => siteSettingsRepository.getSingleton(), {
55
+ // getSiteSettingsGlobal, not the repository directly — the locale layout and
56
+ // generateMetadata already read the same singleton through it, so this is one
57
+ // Firestore read for the whole request tree instead of a second one.
58
+ const siteSettings = await safeRead(() => getSiteSettingsGlobal(), {
50
59
  route: "/", key: "homepage.siteSettings", fallback: null,
51
60
  });
52
61
  // The fallback used to be a hardcoded seasonal Pokémon TCG discount, which
@@ -58,7 +67,8 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
58
67
  safeRead(() => homepageSectionsRepository.getEnabledSections(), {
59
68
  route: "/", key: "homepage.enabledSections", fallback: [],
60
69
  }),
61
- safeRead(() => faqsRepository.getHomepageFAQs(), {
70
+ // Shared with the FAQ JSON-LD built in src/app/[locale]/page.tsx — one read.
71
+ safeRead(() => getHomepageFaqsCached(), {
62
72
  route: "/", key: "homepage.faqs", fallback: [],
63
73
  }),
64
74
  ]);
@@ -47,16 +47,26 @@ export async function fetchLiveStats(requests) {
47
47
  .catch((err) => { void normalizeError(err); serverLogger.warn("live-stats: total_orders query failed — stat omitted", { error: err instanceof Error ? err.message : String(err) }); }));
48
48
  }
49
49
  if (neededPresets.has("total_reviews") || neededPresets.has("platform_rating")) {
50
- tasks.push(reviewRepository.findAll()
51
- .then((res) => {
52
- const all = Array.isArray(res) ? res : (res?.data ?? []);
53
- const approved = all.filter((r) => !r.status || r.status === "approved");
50
+ tasks.push(
51
+ /*
52
+ * 🛑 One aggregation, ~1 read — was `findAll()`, an UNBOUNDED full-collection
53
+ * scan of `reviews` on every cold homepage render, whose entire output was
54
+ * these two strings. Billed one read per document, it was the largest single
55
+ * Firestore read on the site (Hobby free tier is 50K reads/day, and the
56
+ * project measured 2.1M/week).
57
+ *
58
+ * The dropped `!r.status ||` branch was dead: `status` is a REQUIRED field
59
+ * defaulting to `"pending"` (features/reviews/schemas/firestore.ts), so no
60
+ * stored review lacks it, and treating a missing status as approved would
61
+ * have counted unmoderated reviews anyway.
62
+ */
63
+ reviewRepository.getApprovedSiteRatingSummary()
64
+ .then(({ count, average }) => {
54
65
  if (neededPresets.has("total_reviews")) {
55
- resolvedPresets.total_reviews = String(approved.length);
66
+ resolvedPresets.total_reviews = String(count);
56
67
  }
57
- if (neededPresets.has("platform_rating") && approved.length > 0) {
58
- const avg = approved.reduce((sum, r) => sum + (r.rating ?? 0), 0) / approved.length;
59
- resolvedPresets.platform_rating = avg.toFixed(1) + "★";
68
+ if (neededPresets.has("platform_rating") && average !== null) {
69
+ resolvedPresets.platform_rating = average.toFixed(1) + "★";
60
70
  }
61
71
  })
62
72
  .catch((err) => { void normalizeError(err); serverLogger.warn("live-stats: reviews/rating query failed — stat omitted", { error: err instanceof Error ? err.message : String(err) }); }));
@@ -4,7 +4,7 @@ import Image from "next/image";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { Div, Row, Span } from "../../ui";
6
6
  import { ROUNDED_MAP, SHADOW_MAP } from "../../ui/components/surface-tokens";
7
- import { resolveMediaUrl } from "../../utils/media-url";
7
+ import { MEDIA_PROXY_PREFIX, resolveMediaUrl } from "../../utils/media-url";
8
8
  const SIZE_HINTS = {
9
9
  thumbnail: "(max-width: 640px) 80px, (max-width: 1024px) 96px, 112px",
10
10
  card: "(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw",
@@ -32,6 +32,23 @@ const FALLBACK_ICONS = {
32
32
  // icon, instead of failing permanently on the very first request.
33
33
  const MAX_LOAD_RETRIES = 3;
34
34
  const RETRY_BACKOFF_MS = [600, 1200, 2400];
35
+ /**
36
+ * Only `/media/<slug>` may be retried.
37
+ *
38
+ * 🛑 The retry appends `?retry=N`, which mints a BRAND-NEW CDN cache key that is
39
+ * guaranteed cold — so a failing image costs up to 4 cold function invocations
40
+ * instead of 1, on precisely the images that are already flaky. That is a real
41
+ * cost multiplier and it suspended this project once (HTTP 402, 2026-10-09).
42
+ *
43
+ * It is worth paying for our own Storage objects, because the post-upload race
44
+ * above is genuine and transient — retrying actually succeeds. It is NOT worth
45
+ * paying for `/api/media/ext`: there is no upload, the upstream is a third party
46
+ * we do not control, and an upstream that just failed will almost certainly fail
47
+ * again. Those fall straight through to the fallback icon on the first error.
48
+ */
49
+ function isRetryableSrc(resolvedSrc) {
50
+ return resolvedSrc.startsWith(MEDIA_PROXY_PREFIX);
51
+ }
35
52
  /** Resolve every URL in a comma-separated srcset string through the media proxy. */
36
53
  function resolveSrcSet(input) {
37
54
  return input
@@ -78,7 +95,7 @@ export function MediaImage({ src, alt, size = "card", priority = false, loading,
78
95
  className ?? "",
79
96
  ].filter(Boolean).join(" ");
80
97
  const handleError = () => {
81
- if (retryCount < MAX_LOAD_RETRIES) {
98
+ if (resolvedSrc && isRetryableSrc(resolvedSrc) && retryCount < MAX_LOAD_RETRIES) {
82
99
  retryTimeoutRef.current = setTimeout(() => {
83
100
  setRetryCount((count) => count + 1);
84
101
  }, RETRY_BACKOFF_MS[retryCount]);
@@ -180,6 +180,25 @@ export interface OrderDocumentItem {
180
180
  totalPrice: number;
181
181
  /** SB8-F â€" set when the item is a prize-draw entry; drives the reveals badge. */
182
182
  listingType?: ListingType;
183
+ /**
184
+ * The product's LEAF category, snapshotted at order time (C1).
185
+ *
186
+ * 🛑 A SNAPSHOT, not a join, and not for performance alone. `priceIndexRollup`
187
+ * needs to know which category band a realised sale belongs to, and this
188
+ * interface carried no taxonomy field at all — `bundleCategorySlug` is a
189
+ * bundle key, not a category — so the only route was a full products scan
190
+ * per run, which answers nothing for an item whose product has since been
191
+ * deleted.
192
+ *
193
+ * It is also the correct answer rather than the cheap one: a product
194
+ * re-categorised next year must not retroactively move last year's sale into
195
+ * a band it was never part of. Same reasoning as the `hsnCode` / `gstRate` /
196
+ * `finalSale` snapshots below — a past sale's facts are fixed.
197
+ *
198
+ * Absent on every order written before C1, which is why the rollup keeps the
199
+ * products-map fallback rather than treating absence as "no category".
200
+ */
201
+ categorySlug?: string;
183
202
  /** SB8-F â€" per-item reveal status; flips through pending â†' open â†' revealed/closed. */
184
203
  prizeRevealStatus?: "pending" | "open" | "closed" | "revealed";
185
204
  /** Set when the buyer cancels this specific line item post-order (partial cancellation). */
@@ -358,6 +377,57 @@ export interface OrderDocument extends BaseDocument {
358
377
  sgst?: number;
359
378
  /** Inter-state: buyer and seller/store pickup address are in different states. */
360
379
  igst?: number;
380
+ /**
381
+ * Taxable value of 0%-rated (EXEMPT) goods in this order — live plants and
382
+ * animals. **Not** part of `taxableAmount`, which covers positively-rated
383
+ * slices only.
384
+ *
385
+ * 🛑 Without it a mixed exempt/taxed order cannot reconcile its own invoice:
386
+ * `sumGroupGst` skipped exempt slices entirely, so `taxableAmount` excluded
387
+ * them while `items[]` included them, and the Taxable column could never
388
+ * sum to the summary. Exempt is a real, reportable state (nil-rated supply),
389
+ * not an absence.
390
+ */
391
+ exemptAmount?: number;
392
+ /**
393
+ * Per-rate rollup — taxable value and tax for each distinct `gstRate` in the
394
+ * order.
395
+ *
396
+ * 🛑 Required for a Rule 46-compliant invoice, which asks for taxable value
397
+ * and tax **per rate**. The aggregate `taxableAmount`/`gstAmount`/cgst/sgst/
398
+ * igst fields above lose that dimension, so a mixed 5% + 18% order could not
399
+ * produce a compliant document without re-deriving the split downstream —
400
+ * i.e. a second implementation of the loop that already computed it.
401
+ */
402
+ gstByRate?: Array<{
403
+ gstRate: number;
404
+ taxableAmount: number;
405
+ cgst: number;
406
+ sgst: number;
407
+ igst: number;
408
+ gstAmount: number;
409
+ }>;
410
+ /**
411
+ * OUR GST registration as it stood at the moment of supply — LetItRip is the
412
+ * seller of record and invoices under its own GSTIN.
413
+ *
414
+ * 🛑 Snapshotted for two independent reasons, and both matter.
415
+ *
416
+ * **Compliance**: changing our registered details must never rewrite an
417
+ * invoice already issued. Same discipline as `items[].hsnCode` / `gstRate` /
418
+ * `finalSale`, and the same reason `TaxCodeDocument` edits change future
419
+ * derivations only.
420
+ *
421
+ * **Access**: `gst` sits in `PRIVATE_SITE_FIELDS` — withheld from the public
422
+ * site-settings projection precisely because an earlier deny-list version
423
+ * shipped the GSTIN and registered legal name to anonymous callers (Root
424
+ * Cause #70). So the invoice page cannot read it from site settings, and it
425
+ * should not: the buyer is entitled to the GSTIN *on their own invoice*,
426
+ * which is an authenticated per-order read, not public data.
427
+ */
428
+ supplierGstin?: string;
429
+ supplierLegalName?: string;
430
+ supplierAddress?: string;
361
431
  emiEnabled?: boolean;
362
432
  /** Number of monthly installments the buyer chose (2–6). */
363
433
  emiTenureMonths?: number;
@@ -53,6 +53,18 @@ export interface OrderItem {
53
53
  * gate reads the document, so this is a rendering hint only.
54
54
  */
55
55
  finalSale?: boolean;
56
+ /**
57
+ * HSN code and GST rate, snapshotted onto the order item at purchase.
58
+ *
59
+ * 🛑 Surfaced here because the INVOICE needs them per line and could not
60
+ * previously get them: `orderDocumentToOrder` mapped `tax: doc.gstAmount`
61
+ * and nothing else tax-related, so the buyer-facing invoice rendered
62
+ * `["Item", "Qty", "Price"]` and one aggregate Tax row — not because the
63
+ * renderer was lazy, but because the adapter never handed it a rate, an HSN
64
+ * or a split to show. Root Cause #57's exact shape.
65
+ */
66
+ hsnCode?: string;
67
+ gstRate?: 0 | 5 | 12 | 18 | 28;
56
68
  }
57
69
  /**
58
70
  * One entry on an order's timeline — the client-facing shape of
@@ -100,7 +112,50 @@ export interface Order {
100
112
  subtotal: number;
101
113
  shippingCost?: number;
102
114
  discount?: number;
115
+ /** Total GST — product GST plus the GST on our platform fee. */
103
116
  tax?: number;
117
+ /**
118
+ * The GST breakdown the INVOICE needs, and which the adapter did not map
119
+ * until 2026-10-10 — so `/user/orders/[id]/invoice` had no GSTIN, no HSN
120
+ * column, no rate column and one undifferentiated "Tax (GST)" row, and was
121
+ * therefore not Rule 46-compliant in any respect.
122
+ *
123
+ * 🛑 `taxableAmount` excludes `exemptAmount`: a 0%-rated supply is a real
124
+ * reportable state, not an absence, and keeping them separate is what lets
125
+ * the invoice's columns reconcile against its own summary.
126
+ */
127
+ taxableAmount?: number;
128
+ exemptAmount?: number;
129
+ cgst?: number;
130
+ sgst?: number;
131
+ igst?: number;
132
+ /**
133
+ * Taxable value and tax PER RATE — what Rule 46 actually asks for.
134
+ *
135
+ * ✅ This is also why no per-item `gstAmount` was added. Rule 46 requires the
136
+ * tax amount per **rate**, not per **line**; the per-line requirements are
137
+ * HSN, quantity, taxable value and rate, all of which the item already
138
+ * carries or derives from `price × quantity`. A per-item tax amount would be
139
+ * a denormalised mirror of what this array already holds — Root Cause #42's
140
+ * shape — so it was deliberately NOT added.
141
+ */
142
+ gstByRate?: Array<{
143
+ gstRate: number;
144
+ taxableAmount: number;
145
+ cgst: number;
146
+ sgst: number;
147
+ igst: number;
148
+ gstAmount: number;
149
+ }>;
150
+ /**
151
+ * Our GST registration as snapshotted on this order. A tax invoice without
152
+ * the supplier's GSTIN is not a tax invoice — and the invoice page cannot
153
+ * read it from site settings, because `gst` is in `PRIVATE_SITE_FIELDS`
154
+ * after an earlier deny-list projection shipped it publicly (Root Cause #70).
155
+ */
156
+ supplierGstin?: string;
157
+ supplierLegalName?: string;
158
+ supplierAddress?: string;
104
159
  total: number;
105
160
  currency: string;
106
161
  /**
@@ -15,15 +15,6 @@ export interface ProductListActionParams {
15
15
  export type ProductListResult = FirebaseSieveResult<ProductDocument>;
16
16
  export declare function listProducts(params?: ProductListActionParams): Promise<ProductListResult>;
17
17
  export declare function getProductById(id: string): Promise<ProductDocument | null>;
18
- /**
19
- * Featured items for the homepage's generic "Featured Products" strip.
20
- *
21
- * Scoped to `GENERIC_PRODUCT_LISTING_TYPES` for the same reason `/products` is:
22
- * the homepage already has dedicated Live Auctions, Pre-Orders and Prize Draws
23
- * sections, so leaving this unscoped put the same item in two strips on one
24
- * page — and an auction rendered as a generic product card shows Buy-Now chrome
25
- * instead of bid affordances.
26
- */
27
18
  export declare function getFeaturedProducts(pageSize?: number): Promise<ProductListResult>;
28
19
  /**
29
20
  * The soonest-ending LIVE auctions.
@@ -73,6 +73,28 @@ function toListResult(result, pageSize) {
73
73
  * page — and an auction rendered as a generic product card shows Buy-Now chrome
74
74
  * instead of bid affordances.
75
75
  */
76
+ /**
77
+ * How many documents a fixed-size homepage strip may read.
78
+ *
79
+ * 🛑 These strips land on `listPublicProducts`' in-memory window path — every
80
+ * anonymous viewer sets `hidesTestData`, which forces it — and that path pins
81
+ * the fetch to 50 documents regardless of the requested `pageSize`. So
82
+ * `getFeaturedProducts(12)` read 51 to render 12, and the homepage does this
83
+ * four times: ~200 reads for 44 cards, against a 50K-reads/day free tier that
84
+ * the project was exceeding 6× (2.1M/week, measured 2026-10-09).
85
+ *
86
+ * Headroom is genuinely required, not optional: availability and test-data are
87
+ * per-row predicates applied AFTER the fetch, so a window equal to `pageSize`
88
+ * would under-fill the strip the moment one row is unavailable. 2× with a floor
89
+ * of +8 absorbs that while still cutting the read roughly in half.
90
+ *
91
+ * Deliberately NOT derived inside `listPublicProducts` from `pageSize` — a
92
+ * browse page must keep the full 50 so it can page deeply, and silently
93
+ * shrinking its window would drop rows from page 2 onward.
94
+ */
95
+ function featuredWindow(pageSize) {
96
+ return Math.max(pageSize * 2, pageSize + 8);
97
+ }
76
98
  export async function getFeaturedProducts(pageSize = 8) {
77
99
  return toListResult(await listPublicProducts({
78
100
  featured: true,
@@ -81,6 +103,7 @@ export async function getFeaturedProducts(pageSize = 8) {
81
103
  sorts: sortBy("createdAt", "DESC"),
82
104
  page: 1,
83
105
  pageSize,
106
+ windowSize: featuredWindow(pageSize),
84
107
  }), pageSize);
85
108
  }
86
109
  /**
@@ -103,6 +126,7 @@ export async function getFeaturedAuctions(pageSize = 6) {
103
126
  sorts: sortBy(PRODUCT_FIELDS.AUCTION_END_DATE, "ASC"),
104
127
  page: 1,
105
128
  pageSize,
129
+ windowSize: featuredWindow(pageSize),
106
130
  }), pageSize);
107
131
  }
108
132
  export async function getLatestProducts(pageSize = 12) {
@@ -138,6 +162,7 @@ export async function getFeaturedPreOrders(pageSize = 6) {
138
162
  sorts: sortBy(PRODUCT_FIELDS.PRE_ORDER_DELIVERY_DATE, "ASC"),
139
163
  page: 1,
140
164
  pageSize,
165
+ windowSize: featuredWindow(pageSize),
141
166
  }), pageSize);
142
167
  }
143
168
  export async function getLatestPreOrders(pageSize = 12) {
@@ -8,7 +8,6 @@ export declare const PRODUCT_SORT_VALUES: {
8
8
  readonly PRICE_LOW: "price";
9
9
  readonly PRICE_HIGH: "-price";
10
10
  readonly RATING_HIGH: "-avgRating";
11
- readonly POPULAR: "-viewCount";
12
11
  readonly NAME_AZ: "title";
13
12
  readonly NAME_ZA: "-title";
14
13
  };
@@ -6,7 +6,11 @@ export const PRODUCT_SORT_VALUES = {
6
6
  PRICE_LOW: "price",
7
7
  PRICE_HIGH: "-price",
8
8
  RATING_HIGH: "-avgRating",
9
- POPULAR: "-viewCount",
9
+ // 🛑 `POPULAR: "-viewCount"` removed 2026-10-10 — `viewCount` is on no
10
+ // document (0/72 products, 0/58 categories) and nothing writes it, so a
11
+ // Firestore orderBy on it excludes every row. Verified: 0 results against a
12
+ // valid index. See STANDARD_SORT_OPTIONS for the full writeup.
13
+ // `RATING_HIGH` above is fine by contrast — `avgRating` is present on 72/72.
10
14
  NAME_AZ: "title",
11
15
  NAME_ZA: "-title",
12
16
  };
@@ -20,5 +20,11 @@ export async function ArtStickersListView({ searchParams = {} }) {
20
20
  // about the default view (Root Cause #30).
21
21
  ...defaultAvailabilityForListingTypes(ART_STICKERS_LISTING_TYPES),
22
22
  }));
23
- return (_jsxs(Main, { children: [_jsx(Section, { border: "bottom-subtle", paddingY: "b-md", padding: "t-xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", color: "primary", children: "Art & Stickers" }), _jsx(Text, { className: "mt-1", color: "muted", size: "sm", children: "Original art prints and sticker packs from independent sellers" })] }) }), _jsxs(Container, { size: "xl", padding: "x-md", children: [_jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-4 mt-4" }), _jsx(ProductsIndexListing, { initialData: result ?? null, listingTypes: ART_STICKERS_LISTING_TYPES, typeTabs: ART_STICKERS_TYPE_FILTER_TABS, searchPlaceholder: "Search art & stickers..." }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] })] }));
23
+ return (_jsxs(Main, { children: [_jsx(Section, { border: "bottom-subtle", paddingY: "b-md", padding: "t-xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", color: "primary", children: "Art & Stickers" }), _jsx(Text, { className: "mt-1", color: "muted", size: "sm", children: "Original art prints and sticker packs from independent sellers" })] }) }), _jsxs(Container, { size: "xl", padding: "x-md", children: [_jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-4 mt-4" }), _jsx(ProductsIndexListing
24
+ // `undefined`, not `null` — React Query treats null as defined data,
25
+ // so a failed query froze an empty grid. See ProductsIndexPageView.
26
+ , {
27
+ // `undefined`, not `null` — React Query treats null as defined data,
28
+ // so a failed query froze an empty grid. See ProductsIndexPageView.
29
+ initialData: result ?? undefined, listingTypes: ART_STICKERS_LISTING_TYPES, typeTabs: ART_STICKERS_TYPE_FILTER_TABS, searchPlaceholder: "Search art & stickers..." }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] })] }));
24
30
  }