@mohasinac/appkit 4.27.0 → 4.29.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 (298) hide show
  1. package/dist/_internal/client/features/lottery/LotteryAdminEditView.d.ts +16 -16
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +29 -6
  3. package/dist/_internal/client/features/lottery/LotteryListView.js +13 -1
  4. package/dist/_internal/server/features/checkout/actions.js +6 -4
  5. package/dist/_internal/server/features/classified/metadata.js +3 -1
  6. package/dist/_internal/server/features/digital-code/metadata.js +3 -1
  7. package/dist/_internal/server/features/live/metadata.js +3 -1
  8. package/dist/_internal/server/features/refunds/actions.js +3 -2
  9. package/dist/_internal/server/features/reviews/actions.js +3 -2
  10. package/dist/_internal/server/features/seo/sitemap.js +189 -40
  11. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/stores/adapters.js +5 -0
  13. package/dist/_internal/server/jobs/core/listingProcessor.d.ts +0 -1
  14. package/dist/_internal/server/jobs/core/listingProcessor.js +19 -1
  15. package/dist/_internal/shared/actions/action-registry.js +14 -0
  16. package/dist/_internal/shared/checkout/order-math.d.ts +25 -3
  17. package/dist/_internal/shared/features/checkout/config.d.ts +3 -3
  18. package/dist/_internal/shared/features/checkout/config.js +6 -3
  19. package/dist/client.d.ts +32 -2
  20. package/dist/client.js +37 -2
  21. package/dist/constants/api-endpoints.d.ts +21 -3
  22. package/dist/constants/api-endpoints.js +14 -1
  23. package/dist/constants/field-names.d.ts +2 -2
  24. package/dist/constants/field-names.js +2 -2
  25. package/dist/core/newsletter.repository.js +14 -1
  26. package/dist/features/about/components/FAQPageView.js +1 -1
  27. package/dist/features/about/components/FeesView.js +1 -1
  28. package/dist/features/account/components/AddressForm.d.ts +2 -0
  29. package/dist/features/account/components/AddressForm.js +5 -1
  30. package/dist/features/account/components/UserBidsView.js +1 -1
  31. package/dist/features/account/components/UserSidebar.js +1 -1
  32. package/dist/features/account/components/UserSupportView.js +24 -2
  33. package/dist/features/account/hooks/useAddresses.d.ts +29 -1
  34. package/dist/features/account/hooks/useAddresses.js +4 -1
  35. package/dist/features/account/hooks/useProfile.d.ts +1 -1
  36. package/dist/features/account/hooks/useProfile.js +1 -1
  37. package/dist/features/addresses/repository/addresses.repository.d.ts +13 -0
  38. package/dist/features/addresses/repository/addresses.repository.js +19 -0
  39. package/dist/features/admin/actions/chat-actions.js +5 -1
  40. package/dist/features/admin/components/AdminAdsView.js +1 -1
  41. package/dist/features/admin/components/AdminBidsView.js +6 -1
  42. package/dist/features/admin/components/AdminCarouselGroupEditorView.d.ts +9 -1
  43. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +42 -10
  44. package/dist/features/admin/components/AdminCarouselView.js +1 -1
  45. package/dist/features/admin/components/AdminContentGuideView.js +1 -1
  46. package/dist/features/admin/components/AdminFeatureEditorView.js +1 -1
  47. package/dist/features/admin/components/AdminGroupedListingsView.js +14 -2
  48. package/dist/features/admin/components/AdminSidebar.js +1 -1
  49. package/dist/features/admin/components/AdminViewCards.js +2 -2
  50. package/dist/features/admin/components/DataTable.js +2 -2
  51. package/dist/features/admin/schemas/firestore.js +9 -3
  52. package/dist/features/auctions/actions/bid-actions.js +45 -4
  53. package/dist/features/auctions/components/AuctionBottomActions.d.ts +21 -6
  54. package/dist/features/auctions/components/AuctionBottomActions.js +42 -11
  55. package/dist/features/auctions/components/AuctionDetailPageView.d.ts +8 -3
  56. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -2
  57. package/dist/features/auctions/components/MarketplaceAuctionCard.js +4 -5
  58. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +27 -2
  59. package/dist/features/auctions/components/PlaceBidFormClient.js +42 -20
  60. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  61. package/dist/features/auth/hooks/useAuth.d.ts +23 -1
  62. package/dist/features/auth/hooks/useAuth.js +27 -1
  63. package/dist/features/auth/permissions/constants.d.ts +1 -1
  64. package/dist/features/auth/permissions/constants.js +3 -0
  65. package/dist/features/auth/repository/session.repository.d.ts +16 -1
  66. package/dist/features/auth/repository/session.repository.js +23 -0
  67. package/dist/features/auth/repository/user.repository.js +13 -2
  68. package/dist/features/blog/components/BlogListView.js +2 -2
  69. package/dist/features/blog/repository/blog.repository.d.ts +4 -0
  70. package/dist/features/blog/repository/blog.repository.js +1 -0
  71. package/dist/features/blog/schemas/firestore.d.ts +3 -1
  72. package/dist/features/blog/schemas/firestore.js +1 -0
  73. package/dist/features/cart/components/CartPriceBreakdown.d.ts +11 -1
  74. package/dist/features/cart/components/CartPriceBreakdown.js +3 -3
  75. package/dist/features/cart/components/StoreAddonsPicker.d.ts +9 -0
  76. package/dist/features/cart/components/StoreAddonsPicker.js +15 -5
  77. package/dist/features/cart/components/index.d.ts +1 -1
  78. package/dist/features/cart/components/index.js +1 -1
  79. package/dist/features/cart/hooks/useGuestCartMerge.d.ts +8 -1
  80. package/dist/features/cart/hooks/useGuestCartMerge.js +2 -1
  81. package/dist/features/cart/utils/line-total.d.ts +25 -0
  82. package/dist/features/cart/utils/line-total.js +26 -0
  83. package/dist/features/catalogue/repository/catalogue.repository.d.ts +12 -0
  84. package/dist/features/catalogue/repository/catalogue.repository.js +15 -0
  85. package/dist/features/categories/components/CategoryTree.js +2 -2
  86. package/dist/features/contact/components/ContactForm.js +1 -1
  87. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +1 -1
  88. package/dist/features/copilot/hooks/useCopilotFeedback.js +1 -1
  89. package/dist/features/events/components/AdminEventEditorView.js +16 -9
  90. package/dist/features/events/components/AdminEventEntriesView.js +7 -13
  91. package/dist/features/events/components/EventRafflesSection.js +14 -2
  92. package/dist/features/events/components/EventStatusBadge.js +1 -1
  93. package/dist/features/events/components/event-entry-detail-fields.d.ts +24 -0
  94. package/dist/features/events/components/event-entry-detail-fields.js +13 -0
  95. package/dist/features/events/repository/events.repository.js +1 -0
  96. package/dist/features/events/schemas/firestore.d.ts +3 -1
  97. package/dist/features/events/schemas/firestore.js +1 -0
  98. package/dist/features/faq/components/ContactCTA.js +1 -1
  99. package/dist/features/faq/components/FAQHelpfulButtons.js +2 -2
  100. package/dist/features/faq/repository/faqs.repository.d.ts +16 -1
  101. package/dist/features/faq/repository/faqs.repository.js +64 -54
  102. package/dist/features/faq/schemas/firestore.d.ts +3 -2
  103. package/dist/features/faq/schemas/firestore.js +2 -2
  104. package/dist/features/faq/schemas/index.d.ts +3 -3
  105. package/dist/features/faq/schemas/index.js +1 -1
  106. package/dist/features/filters/FilterFacetSection.js +2 -2
  107. package/dist/features/forms/Toggle.js +1 -1
  108. package/dist/features/grouped/components/GroupedListingEditorView.d.ts +18 -0
  109. package/dist/features/grouped/components/GroupedListingEditorView.js +192 -0
  110. package/dist/features/homepage/components/AdvertisementBanner.js +2 -2
  111. package/dist/features/homepage/components/BrandsSection.js +1 -1
  112. package/dist/features/homepage/components/CTABannerSection.js +1 -1
  113. package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +1 -1
  114. package/dist/features/homepage/components/HomepageSectionBoundary.d.ts +39 -0
  115. package/dist/features/homepage/components/HomepageSectionBoundary.js +49 -0
  116. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  117. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  118. package/dist/features/homepage/lib/section-renderer.js +2 -1
  119. package/dist/features/layout/AppLayoutShell.js +5 -5
  120. package/dist/features/layout/BottomActions.js +5 -5
  121. package/dist/features/layout/BottomSheet.js +1 -1
  122. package/dist/features/layout/MainNavbar.js +1 -1
  123. package/dist/features/layout/NavbarLayout.js +1 -1
  124. package/dist/features/layout/TitleBarLayout.js +3 -3
  125. package/dist/features/layout/hooks/useFormBottomActions.d.ts +11 -1
  126. package/dist/features/layout/hooks/useFormBottomActions.js +16 -2
  127. package/dist/features/lottery/schemas/config-write.d.ts +141 -0
  128. package/dist/features/lottery/schemas/config-write.js +139 -0
  129. package/dist/features/media/MediaPickerModal.js +3 -1
  130. package/dist/features/media/MediaVideo.js +4 -2
  131. package/dist/features/media/components/MediaSlider.js +1 -1
  132. package/dist/features/messages/repository/conversations.repository.js +15 -5
  133. package/dist/features/orders/components/OrdersList.js +2 -2
  134. package/dist/features/payments/repository/payout.repository.d.ts +11 -1
  135. package/dist/features/payments/repository/payout.repository.js +14 -6
  136. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
  137. package/dist/features/pre-orders/components/PreOrderBadge.js +1 -1
  138. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -1
  139. package/dist/features/products/columns/productTableColumns.js +3 -3
  140. package/dist/features/products/components/CompareOverlay.js +1 -1
  141. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
  142. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  143. package/dist/features/products/components/PrizeDrawsSection.js +5 -1
  144. package/dist/features/products/components/ProductDetailActions.js +11 -7
  145. package/dist/features/products/components/ProductDetailPageView.js +3 -3
  146. package/dist/features/products/components/ProductFeatureBadges.js +2 -2
  147. package/dist/features/products/components/ProductFeaturesSelector.js +1 -1
  148. package/dist/features/products/components/ProductFilters.js +15 -8
  149. package/dist/features/products/components/ProductGrid.js +8 -17
  150. package/dist/features/products/components/ProductTabs.js +7 -4
  151. package/dist/features/products/components/ProductTabsShell.js +2 -7
  152. package/dist/features/products/constants/action-defs.d.ts +21 -4
  153. package/dist/features/products/constants/action-defs.js +7 -3
  154. package/dist/features/products/hooks/useRelatedProducts.js +2 -1
  155. package/dist/features/products/repository/products.repository.d.ts +1 -1
  156. package/dist/features/products/repository/products.repository.js +38 -16
  157. package/dist/features/products/schemas/firestore.d.ts +3 -2
  158. package/dist/features/products/schemas/firestore.js +1 -1
  159. package/dist/features/reviews/actions/review-actions.d.ts +8 -1
  160. package/dist/features/reviews/actions/review-actions.js +14 -3
  161. package/dist/features/reviews/components/ReviewDetailShell.js +10 -4
  162. package/dist/features/reviews/components/ReviewModal.js +1 -1
  163. package/dist/features/reviews/repository/reviews.repository.d.ts +29 -1
  164. package/dist/features/reviews/repository/reviews.repository.js +54 -13
  165. package/dist/features/reviews/schemas/firestore.d.ts +9 -1
  166. package/dist/features/reviews/schemas/firestore.js +1 -0
  167. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  168. package/dist/features/search/components/Search.js +3 -3
  169. package/dist/features/search/components/SearchResultsSection.js +1 -1
  170. package/dist/features/seller/components/SellerAddressesView.js +3 -1
  171. package/dist/features/seller/components/SellerBidsView.js +6 -1
  172. package/dist/features/seller/components/SellerSidebar.js +1 -1
  173. package/dist/features/shell/SectionForm.d.ts +21 -1
  174. package/dist/features/shell/SectionForm.js +15 -3
  175. package/dist/features/shell/build-sections.js +10 -1
  176. package/dist/features/shell/field-ui-meta.d.ts +21 -0
  177. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +23 -6
  178. package/dist/features/site-settings/components/ActionPermissionsManager.js +1 -1
  179. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +21 -4
  180. package/dist/features/store-extensions/repository/store-extensions.repositories.js +24 -4
  181. package/dist/features/store-extensions/schemas/shipping-config-form.js +13 -0
  182. package/dist/features/store-extensions/schemas/store-category-form.js +1 -1
  183. package/dist/features/stores/actions/store-query-actions.js +25 -1
  184. package/dist/features/stores/components/InteractiveStoreCard.js +1 -1
  185. package/dist/features/stores/components/StoreHeader.js +1 -1
  186. package/dist/features/stores/components/StoreNavTabs.js +20 -8
  187. package/dist/features/stores/repository/store.repository.js +1 -0
  188. package/dist/features/stores/schemas/firestore.d.ts +2 -0
  189. package/dist/features/support/repository/support.repository.d.ts +12 -0
  190. package/dist/features/support/repository/support.repository.js +15 -0
  191. package/dist/features/support/schemas/ticket-create-form.d.ts +33 -0
  192. package/dist/features/support/schemas/ticket-create-form.js +82 -0
  193. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +0 -1
  194. package/dist/features/tester/repository/tester-checklist-item.repository.js +6 -15
  195. package/dist/features/tester/schemas/firestore.d.ts +5 -4
  196. package/dist/features/tester/schemas/firestore.js +3 -3
  197. package/dist/features/tester/seed-data/products-tester-seed-data.js +2 -2
  198. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +138 -1
  199. package/dist/features/wishlist/components/WishlistHeartButton.d.ts +55 -0
  200. package/dist/features/wishlist/components/WishlistHeartButton.js +52 -0
  201. package/dist/features/wishlist/components/index.d.ts +2 -1
  202. package/dist/features/wishlist/components/index.js +1 -1
  203. package/dist/index.d.ts +10 -3
  204. package/dist/index.js +19 -4
  205. package/dist/monitoring/error-tracking.js +39 -5
  206. package/dist/monitoring/server-logger.js +70 -32
  207. package/dist/next/components/ErrorView.js +1 -1
  208. package/dist/next/routing/route-map.d.ts +39 -7
  209. package/dist/next/routing/route-map.js +34 -4
  210. package/dist/providers/db-firebase/base.repository.d.ts +30 -0
  211. package/dist/providers/db-firebase/base.repository.js +56 -9
  212. package/dist/react/ErrorBoundary.js +9 -1
  213. package/dist/react/contexts/SessionContext.js +6 -2
  214. package/dist/react/hooks/useAuthGate.d.ts +22 -0
  215. package/dist/react/hooks/useAuthGate.js +86 -7
  216. package/dist/schemas/registry.d.ts +3 -3
  217. package/dist/security/index.d.ts +1 -1
  218. package/dist/security/index.js +1 -1
  219. package/dist/security/pii-encrypt.d.ts +13 -5
  220. package/dist/security/pii-encrypt.js +132 -6
  221. package/dist/security/pii-schemas.d.ts +32 -4
  222. package/dist/security/pii-schemas.js +38 -5
  223. package/dist/seed/_helpers/product-search-txt.d.ts +18 -0
  224. package/dist/seed/_helpers/product-search-txt.js +33 -0
  225. package/dist/seed/_helpers/search-txt-wrappers.d.ts +20 -0
  226. package/dist/seed/_helpers/search-txt-wrappers.js +73 -0
  227. package/dist/seed/blog-posts-seed-data.d.ts +4 -0
  228. package/dist/seed/blog-posts-seed-data.js +7 -1
  229. package/dist/seed/events-seed-data.d.ts +5 -1
  230. package/dist/seed/events-seed-data.js +7 -1
  231. package/dist/seed/faq-seed-data.d.ts +16 -0
  232. package/dist/seed/faq-seed-data.js +22 -1
  233. package/dist/seed/products-art-seed-data.js +49 -7
  234. package/dist/seed/products-auctions-seed-data.js +12 -4
  235. package/dist/seed/products-classifieds-seed-data.d.ts +5 -0
  236. package/dist/seed/products-classifieds-seed-data.js +65 -9
  237. package/dist/seed/products-digital-codes-seed-data.d.ts +5 -0
  238. package/dist/seed/products-digital-codes-seed-data.js +65 -9
  239. package/dist/seed/products-live-items-seed-data.d.ts +5 -0
  240. package/dist/seed/products-live-items-seed-data.js +33 -5
  241. package/dist/seed/products-preorders-seed-data.d.ts +1 -1
  242. package/dist/seed/products-preorders-seed-data.js +6 -2
  243. package/dist/seed/products-prize-draws-seed-data.d.ts +5 -0
  244. package/dist/seed/products-prize-draws-seed-data.js +57 -8
  245. package/dist/seed/products-standard-seed-data.js +6 -2
  246. package/dist/seed/products-stickers-seed-data.js +49 -7
  247. package/dist/seed/reviews-seed-data.js +2 -1
  248. package/dist/seed/site-settings-seed-data.js +5 -3
  249. package/dist/seed/stores-seed-data.d.ts +4 -0
  250. package/dist/seed/stores-seed-data.js +7 -1
  251. package/dist/seo/json-ld.d.ts +44 -2
  252. package/dist/seo/json-ld.js +57 -11
  253. package/dist/styles.css +368 -90
  254. package/dist/tailwind-utilities.css +1 -1
  255. package/dist/tokens/index.d.ts +1 -1
  256. package/dist/tokens/index.js +1 -1
  257. package/dist/tokens/themes/default-dark.js +6 -0
  258. package/dist/tokens/themes/default-light.js +5 -0
  259. package/dist/tokens/tokens.css +84 -0
  260. package/dist/ui/DataTable.js +1 -1
  261. package/dist/ui/DataTable.style.css +3 -3
  262. package/dist/ui/components/BulkActionBar.style.css +1 -3
  263. package/dist/ui/components/Button.js +15 -6
  264. package/dist/ui/components/Dropdown.style.css +1 -4
  265. package/dist/ui/components/FilterChipGroup.js +1 -1
  266. package/dist/ui/components/FilterDrawer.style.css +1 -1
  267. package/dist/ui/components/Icon.d.ts +53 -0
  268. package/dist/ui/components/Icon.js +41 -0
  269. package/dist/ui/components/IconButton.d.ts +13 -0
  270. package/dist/ui/components/IconButton.js +13 -0
  271. package/dist/ui/components/IconButton.style.css +18 -10
  272. package/dist/ui/components/ListingLayout.style.css +2 -10
  273. package/dist/ui/components/ListingToolbar.js +6 -6
  274. package/dist/ui/components/Modal.js +1 -1
  275. package/dist/ui/components/PaginatedSelect.style.css +3 -3
  276. package/dist/ui/components/Pagination.style.css +1 -6
  277. package/dist/ui/components/RichTextEditor.js +1 -1
  278. package/dist/ui/components/RowActionMenu.style.css +1 -1
  279. package/dist/ui/components/SideDrawer.style.css +1 -1
  280. package/dist/ui/components/Slider.js +1 -1
  281. package/dist/ui/components/TabBarShell.d.ts +78 -0
  282. package/dist/ui/components/TabBarShell.js +95 -0
  283. package/dist/ui/components/Tabs.js +12 -8
  284. package/dist/ui/components/Tabs.style.css +249 -41
  285. package/dist/ui/components/ViewToggle.style.css +1 -5
  286. package/dist/ui/components/tabs-overflow.d.ts +17 -0
  287. package/dist/ui/components/tabs-overflow.js +142 -0
  288. package/dist/ui/icons/icon-registry.d.ts +38 -0
  289. package/dist/ui/icons/icon-registry.js +37 -0
  290. package/dist/ui/index.d.ts +6 -0
  291. package/dist/ui/index.js +3 -0
  292. package/dist/utils/index.d.ts +1 -1
  293. package/dist/utils/index.js +1 -1
  294. package/dist/utils/media-url.d.ts +27 -4
  295. package/dist/utils/media-url.js +37 -6
  296. package/dist/utils/search-txt.d.ts +71 -0
  297. package/dist/utils/search-txt.js +118 -0
  298. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { LotteryConfigWriteInput } from "../../../../features/lottery/schemas/config-write";
2
3
  interface LotterySlotRow {
3
4
  slotNumber: number;
4
5
  name: string;
@@ -6,26 +7,25 @@ interface LotterySlotRow {
6
7
  /** Prize photo — rendered publicly in the prize collage on the detail page. */
7
8
  image?: string;
8
9
  }
10
+ /**
11
+ * What this form hands its caller.
12
+ *
13
+ * `lotteryConfig` is the WRITE input — deliberately not `LotteryConfig`. It
14
+ * carries no `isBooked`, no `bookedBy*` and no `weight`, because an admin
15
+ * editing a lottery is describing prizes, not attendance. The stored config
16
+ * gains those in `mergeLotteryConfig`, server-side, from the slots that are
17
+ * already there.
18
+ *
19
+ * `totalSlots` is absent for the same reason: derived from `slots.length` by
20
+ * the merge, never sent. A caller-supplied count that disagrees with the array
21
+ * is the mirror-drift trap (Root Cause #42), and the count is what the
22
+ * fullness check reads.
23
+ */
9
24
  interface LotteryEventFormData {
10
25
  title: string;
11
26
  description?: string;
12
27
  type: "lottery";
13
- lotteryConfig: {
14
- slots: Array<{
15
- slotNumber: number;
16
- name: string;
17
- image?: string;
18
- price: number;
19
- weight: number;
20
- isBooked: boolean;
21
- }>;
22
- totalSlots: number;
23
- pricingMode: "uniform" | "variable";
24
- uniformPrice?: number;
25
- drawWindowDurationMinutes: number;
26
- maxPullsPerTransaction: number;
27
- maxPullsPerUser: number;
28
- };
28
+ lotteryConfig: LotteryConfigWriteInput;
29
29
  }
30
30
  interface LotteryAdminEditViewProps {
31
31
  eventId?: string;
@@ -5,6 +5,7 @@ import { normalizeError } from "../../../../errors/normalize";
5
5
  import { Stack, Row, Text, Heading, Section, Container, Button, DataTable, } from "../../../../ui";
6
6
  import { Form, FieldInput, FieldSelect, Input } from "../../../../ui";
7
7
  import { lotteryConfigFormSchema } from "../../../shared/features/lottery/config-form";
8
+ import { lotteryConfigWriteSchema } from "../../../../features/lottery/schemas/config-write";
8
9
  import { FormErrorSummary } from "../../../../ui/forms/FormErrorSummary";
9
10
  export function LotteryAdminEditView({ eventId, initialData, onSubmit, }) {
10
11
  const [isPending, startTransition] = useTransition();
@@ -35,23 +36,45 @@ export function LotteryAdminEditView({ eventId, initialData, onSubmit, }) {
35
36
  setError(null);
36
37
  startTransition(async () => {
37
38
  try {
38
- const lotteryConfig = {
39
+ /*
40
+ * 🛑 This used to send `isBooked: false` and `weight: 0` on every slot,
41
+ * and drop `bookedByUserId` / `bookedByDisplayName` /
42
+ * `bookedByUserLotteryNumber` entirely — against a `.passthrough()`
43
+ * PATCH, which made the first save of a live lottery erase every
44
+ * purchased slot, silently, with a 200.
45
+ *
46
+ * The write shape now has NO booking fields at all and is `.strict()`,
47
+ * so a slot's buyer is not something this form can express, let alone
48
+ * overwrite. `PUT /lottery-config` merges by `slotNumber` against the
49
+ * stored config and is the only writer.
50
+ */
51
+ const candidate = {
39
52
  slots: slots.map((s) => ({
40
53
  slotNumber: s.slotNumber,
41
54
  name: s.name,
42
55
  image: s.image?.trim() || undefined,
43
56
  price: s.price,
44
- weight: 0,
45
- isBooked: false,
46
57
  })),
47
- totalSlots: slots.length,
48
58
  pricingMode,
49
- uniformPrice: pricingMode === "uniform" ? Math.round(parseFloat(uniformPrice) * 100) / 100 || 0 : undefined,
59
+ uniformPrice: pricingMode === "uniform"
60
+ ? Math.round(parseFloat(uniformPrice) * 100) / 100 || 0
61
+ : undefined,
50
62
  drawWindowDurationMinutes: parseInt(drawWindowMinutes, 10) || 5,
51
63
  maxPullsPerTransaction: parseInt(maxPullsPerTx, 10) || 1,
52
64
  maxPullsPerUser: parseInt(maxPullsPerUser, 10) || 1,
53
65
  };
54
- await onSubmit({ title, description, type: "lottery", lotteryConfig });
66
+ /*
67
+ * The schema was passed to `<Form>` and never actually run — so the
68
+ * `parseFloat(x) || 0` coercions above, which it exists to catch, were
69
+ * still live. Parsed here so a bad slot is refused on the client
70
+ * rather than discovered as a 400.
71
+ */
72
+ const parsed = lotteryConfigWriteSchema.safeParse(candidate);
73
+ if (!parsed.success) {
74
+ setError(parsed.error.issues[0]?.message ?? "Check the lottery settings.");
75
+ return;
76
+ }
77
+ await onSubmit({ title, description, type: "lottery", lotteryConfig: parsed.data });
55
78
  }
56
79
  catch (err) {
57
80
  void normalizeError(err);
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Stack, Row, Text, Heading, Badge, Card, Section, Container, Anchor, Div, Grid, } from "../../../../ui";
4
4
  import { MediaImage } from "../../../../features/media/MediaImage";
5
+ import { ROUTES } from "../../../../next/routing/route-map";
5
6
  /**
6
7
  * Card grid of lotteries.
7
8
  * Admin sees all statuses; public sees active only.
@@ -17,7 +18,18 @@ export function LotteryListView({ items, adminMode = false }) {
17
18
  const claimed = event.lotteryConfig?.slots.filter((s) => s.isBooked).length ?? 0;
18
19
  const total = event.lotteryConfig?.totalSlots ?? 0;
19
20
  const slotsLeft = total - claimed;
20
- return (_jsx(Anchor, { href: `/lottery/${event.id}`, tone: "brand", rounded: "2xl", className: "block focus:outline-none focus-visible:ring-2 focus-visible:ring-primary", "aria-label": `${event.title} lottery`, children: _jsxs(Card, { variant: "default", padding: "none", animate: "hoverLift", className: "overflow-hidden", children: [event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: "relative aspect-video overflow-hidden", children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: event.title, size: "card" }) })) : (_jsx(Div, { className: "aspect-video", surface: "muted", align: "center", justify: "center", children: _jsx(Text, { size: "3xl", children: "\uD83C\uDFB0" }) })), _jsxs(Stack, { gap: "xs", padding: "md", children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, children: [_jsx(Badge, { variant: event.status === "active"
21
+ return (_jsx(Anchor, {
22
+ /*
23
+ * In adminMode this used to link to the PUBLIC lottery page,
24
+ * so an admin on the admin list had no route to slot
25
+ * configuration at all — which went unnoticed because the
26
+ * editor was orphaned and there was nowhere to link TO.
27
+ * Nested anchors are invalid, so the card itself changes
28
+ * target rather than gaining a second link.
29
+ */
30
+ href: adminMode
31
+ ? String(ROUTES.ADMIN.LOTTERY_CONFIG(event.id))
32
+ : `/lottery/${event.id}`, tone: "brand", rounded: "2xl", className: "block focus:outline-none focus-visible:ring-2 focus-visible:ring-primary", "aria-label": `${event.title} lottery`, children: _jsxs(Card, { variant: "default", padding: "none", animate: "hoverLift", className: "overflow-hidden", children: [event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: "relative aspect-video overflow-hidden", children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: event.title, size: "card" }) })) : (_jsx(Div, { className: "aspect-video", surface: "muted", align: "center", justify: "center", children: _jsx(Text, { size: "3xl", children: "\uD83C\uDFB0" }) })), _jsxs(Stack, { gap: "xs", padding: "md", children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, children: [_jsx(Badge, { variant: event.status === "active"
21
33
  ? "success"
22
34
  : event.status === "ended"
23
35
  ? "danger"
@@ -80,7 +80,7 @@ async function claimDigitalCodeForOrder(db, productId, orderId, userId, opts) {
80
80
  userName: opts.userName ?? "there",
81
81
  productTitle: opts.productTitle ?? "your product",
82
82
  orderId,
83
- }).catch((e) => serverLogger.error("claimDigitalCode: email failed", e));
83
+ }).catch((e) => serverLogger.error("claimDigitalCode: email failed", { error: normalizeError(e).message }));
84
84
  }
85
85
  }
86
86
  /**
@@ -550,7 +550,7 @@ async function createOrderForGroup(group, orderType, ctx) {
550
550
  userEmail: userEmail || undefined,
551
551
  userName,
552
552
  productTitle: firstItem.productTitle,
553
- }).catch((e) => serverLogger.error("claimDigitalCode", e));
553
+ }).catch((e) => serverLogger.error("claimDigitalCode", { error: normalizeError(e).message }));
554
554
  }
555
555
  for (const code of groupCouponCodes) {
556
556
  const entry = couponUsageAccumulator.get(code);
@@ -892,8 +892,10 @@ export async function previewCheckoutPricing(input) {
892
892
  // A preview never throws — it just shows the lane the buyer can actually
893
893
  // check out, matching what the checkout page's active tab displays.
894
894
  (!previewLane || laneOf(item) === previewLane));
895
+ // A fresh object, not the module singleton — an in-process caller mutating
896
+ // the returned preview would otherwise poison every later empty result.
895
897
  if (cartItems.length === 0)
896
- return EMPTY_PRICING_PREVIEW;
898
+ return { ...EMPTY_PRICING_PREVIEW, stores: [] };
897
899
  // Best-effort product lookup — a preview never throws on a since-unpublished
898
900
  // product, it just falls back to the item's cart-snapshot price for that
899
901
  // line (same fallback cartRepository.getSubtotal() implicitly relies on).
@@ -1268,7 +1270,7 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
1268
1270
  userEmail: userEmail || undefined,
1269
1271
  userName,
1270
1272
  productTitle: firstItem.productTitle,
1271
- }).catch((e) => serverLogger.error("claimDigitalCode", e));
1273
+ }).catch((e) => serverLogger.error("claimDigitalCode", { error: normalizeError(e).message }));
1272
1274
  }
1273
1275
  for (const code of groupCouponCodes) {
1274
1276
  const entry = couponUsageAccumulator.get(code);
@@ -21,7 +21,9 @@ export function buildClassifiedMetadata(product, opts) {
21
21
  : product.description?.slice(0, 160) ?? "";
22
22
  const image = opts?.ogImageUrl ?? (product.mainImage || product.images?.[0] || null);
23
23
  const siteUrl = opts?.siteUrl?.replace(/\/+$/, "");
24
- const canonical = siteUrl ? `${siteUrl}/classified/${product.id}` : undefined;
24
+ // Prefer `slug` — the route is `/classified/[slug]`, so a canonical built from
25
+ // `id` names a URL the router does not serve whenever the two differ.
26
+ const canonical = siteUrl ? `${siteUrl}/classified/${product.slug ?? product.id}` : undefined;
25
27
  return {
26
28
  title,
27
29
  description,
@@ -14,7 +14,9 @@ export function buildDigitalCodeMetadata(product, opts) {
14
14
  : product.description?.slice(0, 160) ?? "";
15
15
  const image = opts?.ogImageUrl ?? (product.mainImage || product.images?.[0] || null);
16
16
  const siteUrl = opts?.siteUrl?.replace(/\/+$/, "");
17
- const canonical = siteUrl ? `${siteUrl}/digital-codes/${product.id}` : undefined;
17
+ // Prefer `slug` — the route is `/digital-codes/[slug]`, so a canonical built
18
+ // from `id` names a URL the router does not serve whenever the two differ.
19
+ const canonical = siteUrl ? `${siteUrl}/digital-codes/${product.slug ?? product.id}` : undefined;
18
20
  return {
19
21
  title,
20
22
  description,
@@ -17,7 +17,9 @@ export function buildLiveItemMetadata(product, opts) {
17
17
  : product.description?.slice(0, 160) ?? "";
18
18
  const image = opts?.ogImageUrl ?? (product.mainImage || product.images?.[0] || null);
19
19
  const siteUrl = opts?.siteUrl?.replace(/\/+$/, "");
20
- const canonical = siteUrl ? `${siteUrl}/live/${product.id}` : undefined;
20
+ // Prefer `slug` — the route is `/live/[slug]`, so a canonical built from `id`
21
+ // names a URL the router does not serve whenever the two differ.
22
+ const canonical = siteUrl ? `${siteUrl}/live/${product.slug ?? product.id}` : undefined;
21
23
  return {
22
24
  title,
23
25
  description,
@@ -16,6 +16,7 @@ import { wrapAction } from "@mohasinac/appkit/server";
16
16
  */
17
17
  import { randomUUID } from "crypto";
18
18
  import { getProviders } from "../../../../contracts/registry";
19
+ import { normalizeError } from "../../../../errors/normalize";
19
20
  import { rupeesToPaise } from "../../../../providers/payment-razorpay/index";
20
21
  import { orderRepository } from "../../../..";
21
22
  import { ORDER_FIELDS } from "../../../../constants/field-names";
@@ -65,7 +66,7 @@ function revokeAvailableDigitalCodes(orderId, items) {
65
66
  .limit(1)
66
67
  .get()
67
68
  .then((snap) => revokeFirstSnapDoc(snap))
68
- .catch((e) => serverLogger.error("refund: code query failed", e));
69
+ .catch((e) => serverLogger.error("refund: code query failed", { error: normalizeError(e).message }));
69
70
  }
70
71
  }
71
72
  function revokeFirstSnapDoc(snap) {
@@ -73,7 +74,7 @@ function revokeFirstSnapDoc(snap) {
73
74
  return;
74
75
  snap.docs[0].ref
75
76
  .update({ status: "revoked", updatedAt: new Date() })
76
- .catch((e) => serverLogger.error("refund: code revoke failed", e));
77
+ .catch((e) => serverLogger.error("refund: code revoke failed", { error: normalizeError(e).message }));
77
78
  }
78
79
  export async function processRefundAction(input) {
79
80
  return wrapAction(async () => {
@@ -71,8 +71,9 @@ export async function deleteReviewAction(input) {
71
71
  }
72
72
  export async function markReviewHelpfulAction(reviewId) {
73
73
  return wrapAction(async () => {
74
- await requireRoleUser(["buyer", "seller", "admin"]);
74
+ const user = await requireRoleUser(["buyer", "seller", "admin"]);
75
75
  await getReviewOrThrow(reviewId);
76
- await reviewRepository.incrementHelpful(reviewId);
76
+ // Keyed on the voter so a repeated call is a no-op rather than another +1.
77
+ return reviewRepository.voteHelpful(reviewId, user.uid);
77
78
  });
78
79
  }
@@ -14,6 +14,50 @@ const PRODUCT_STATUS = "status";
14
14
  const PRODUCT_STATUS_PUBLISHED = "published";
15
15
  const PRODUCT_SLUG = "slug";
16
16
  const PRODUCT_UPDATED_AT = "updatedAt";
17
+ /**
18
+ * Tester-sandbox fixtures must never reach the public sitemap. They are created
19
+ * and destroyed on a cycle by `testerSandboxCleanup` / `testerSandboxRefresh`,
20
+ * so anything Google indexes from them 404s shortly after. 34 of 182 sitemap
21
+ * URLs were disposable test fixtures before this filter existed.
22
+ *
23
+ * 🛑 This MUST be an in-memory filter, never a Firestore inequality on the
24
+ * field. An inequality silently excludes every document that does not have the
25
+ * field at all — which is every real product, store, category and post, since
26
+ * the flag is optional and was added later. Such a query returns ONLY test
27
+ * data. Same trap documented in CLAUDE.md § Tester QA Program.
28
+ *
29
+ * The field must therefore appear in each fetcher's `.select(...)` list, or it
30
+ * comes back undefined and nothing is filtered.
31
+ *
32
+ * The flag is not product-specific — it is set on categories, stores, blog
33
+ * posts and events too — but `PRODUCT_FIELDS` is where the canonical constant
34
+ * lives, so alias it rather than reintroducing a raw string.
35
+ */
36
+ const TEST_DATA_FIELD = PRODUCT_FIELDS.IS_TEST_DATA;
37
+ const isTestDoc = (data) => data.isTestData === true;
38
+ /**
39
+ * A sitemap section that throws returns `[]` so a single broken query degrades
40
+ * the sitemap instead of 500-ing the whole route. That trade-off is right, but
41
+ * it is also why `fetchCategoryUrls` could return zero URLs for months without
42
+ * anyone noticing — it was logged at `warn` and looked identical to "this site
43
+ * genuinely has no categories".
44
+ *
45
+ * Log at `error`, and always name the section, so a section dropping to zero is
46
+ * greppable in production logs. `buildSitemap` additionally logs a per-section
47
+ * count digest on every build (see below), and `scripts/deploy.mjs` fails the
48
+ * deploy when any section is empty.
49
+ *
50
+ * NOTE: callers must still call `normalizeError(err)` themselves, in the catch
51
+ * block, BEFORE delegating here. `audit-catch-normalize` requires that call to
52
+ * be lexically inside the `catch` — a helper that normalises on the caller's
53
+ * behalf reads as an unnormalised catch and fails the audit. Keeping the call at
54
+ * the catch site is also what guarantees `unknown` never escapes into downstream
55
+ * logic, which is the rule's actual point.
56
+ */
57
+ function sitemapSectionFailed(label, err) {
58
+ serverLogger.error(`sitemap: section "${label}" failed and returned 0 URLs`, { section: label, error: err });
59
+ return [];
60
+ }
17
61
  function staticPages(baseUrl) {
18
62
  const page = (path, changeFreq, priority) => ({ url: `${baseUrl}${path}`, lastModified: new Date(), changeFrequency: changeFreq, priority });
19
63
  return [
@@ -70,10 +114,12 @@ async function fetchProductUrls(baseUrl) {
70
114
  .collection(PRODUCT_COLLECTION)
71
115
  .where(PRODUCT_STATUS, "==", PRODUCT_STATUS_PUBLISHED)
72
116
  .where("listingType", "in", PRODUCT_DETAIL_LISTING_TYPES)
73
- .select(PRODUCT_SLUG, PRODUCT_UPDATED_AT)
117
+ .select(PRODUCT_SLUG, PRODUCT_UPDATED_AT, TEST_DATA_FIELD)
74
118
  .limit(5000)
75
119
  .get();
76
- return snap.docs.map((doc) => {
120
+ return snap.docs
121
+ .filter((doc) => !isTestDoc(doc.data()))
122
+ .map((doc) => {
77
123
  const data = doc.data();
78
124
  const slug = data[PRODUCT_SLUG] ?? doc.id;
79
125
  return {
@@ -86,8 +132,7 @@ async function fetchProductUrls(baseUrl) {
86
132
  }
87
133
  catch (err) {
88
134
  void normalizeError(err);
89
- serverLogger.warn("sitemap: failed to fetch product URLs", { error: err });
90
- return [];
135
+ return sitemapSectionFailed("product", err);
91
136
  }
92
137
  }
93
138
  async function fetchListingTypeUrls(baseUrl, listingType, buildUrl, label) {
@@ -97,10 +142,12 @@ async function fetchListingTypeUrls(baseUrl, listingType, buildUrl, label) {
97
142
  .collection(PRODUCT_COLLECTION)
98
143
  .where(PRODUCT_STATUS, "==", PRODUCT_STATUS_PUBLISHED)
99
144
  .where("listingType", "==", listingType)
100
- .select(PRODUCT_SLUG, PRODUCT_UPDATED_AT)
145
+ .select(PRODUCT_SLUG, PRODUCT_UPDATED_AT, TEST_DATA_FIELD)
101
146
  .limit(2000)
102
147
  .get();
103
- return snap.docs.map((doc) => {
148
+ return snap.docs
149
+ .filter((doc) => !isTestDoc(doc.data()))
150
+ .map((doc) => {
104
151
  const data = doc.data();
105
152
  const slug = data[PRODUCT_SLUG] ?? doc.id;
106
153
  return {
@@ -113,8 +160,7 @@ async function fetchListingTypeUrls(baseUrl, listingType, buildUrl, label) {
113
160
  }
114
161
  catch (err) {
115
162
  void normalizeError(err);
116
- serverLogger.warn(`sitemap: failed to fetch ${label} URLs`, { error: err });
117
- return [];
163
+ return sitemapSectionFailed(label, err);
118
164
  }
119
165
  }
120
166
  const fetchPreOrderUrls = (baseUrl) => fetchListingTypeUrls(baseUrl, "pre-order", (slug) => ROUTES.PUBLIC.PRE_ORDER_DETAIL(slug), "pre-order");
@@ -129,10 +175,12 @@ async function fetchAuctionUrls(baseUrl) {
129
175
  .collection(PRODUCT_COLLECTION)
130
176
  .where(PRODUCT_STATUS, "==", PRODUCT_STATUS_PUBLISHED)
131
177
  .where("listingType", "==", "auction")
132
- .select(PRODUCT_SLUG, PRODUCT_UPDATED_AT)
178
+ .select(PRODUCT_SLUG, PRODUCT_UPDATED_AT, TEST_DATA_FIELD)
133
179
  .limit(2000)
134
180
  .get();
135
- return snap.docs.map((doc) => {
181
+ return snap.docs
182
+ .filter((doc) => !isTestDoc(doc.data()))
183
+ .map((doc) => {
136
184
  const data = doc.data();
137
185
  const slug = data[PRODUCT_SLUG] ?? doc.id;
138
186
  return {
@@ -145,8 +193,7 @@ async function fetchAuctionUrls(baseUrl) {
145
193
  }
146
194
  catch (err) {
147
195
  void normalizeError(err);
148
- serverLogger.warn("sitemap: failed to fetch auction URLs", { error: err });
149
- return [];
196
+ return sitemapSectionFailed("auction", err);
150
197
  }
151
198
  }
152
199
  async function fetchEventUrls(baseUrl) {
@@ -155,10 +202,12 @@ async function fetchEventUrls(baseUrl) {
155
202
  const snap = await db
156
203
  .collection(EVENTS_COLLECTION)
157
204
  .where(EVENT_FIELDS.STATUS, "==", EVENT_FIELDS.STATUS_VALUES.ACTIVE)
158
- .select(EVENT_FIELDS.UPDATED_AT, EVENT_FIELDS.SLUG)
205
+ .select(EVENT_FIELDS.UPDATED_AT, EVENT_FIELDS.SLUG, TEST_DATA_FIELD)
159
206
  .limit(500)
160
207
  .get();
161
- return snap.docs.map((doc) => {
208
+ return snap.docs
209
+ .filter((doc) => !isTestDoc(doc.data()))
210
+ .map((doc) => {
162
211
  const data = doc.data();
163
212
  const slug = typeof data[EVENT_FIELDS.SLUG] === "string" ? data[EVENT_FIELDS.SLUG] : undefined;
164
213
  return {
@@ -171,15 +220,25 @@ async function fetchEventUrls(baseUrl) {
171
220
  }
172
221
  catch (err) {
173
222
  void normalizeError(err);
174
- serverLogger.warn("sitemap: failed to fetch event URLs", { error: err });
175
- return [];
223
+ return sitemapSectionFailed("event", err);
176
224
  }
177
225
  }
178
- // categories is a discriminated-union collection (categoryType: "listing" |
179
- // "brand" | "bundle" — see CLAUDE.md's SB-UNI-C/SB-UNI-D). Each discriminator
180
- // renders at a different public route, so each needs its own filtered query;
181
- // querying the collection without a categoryType filter (the prior bug here)
182
- // mapped brand/bundle rows onto the wrong /categories/{slug} URL.
226
+ // `categories` is a discriminated-union collection. Each discriminator renders
227
+ // at a different public route, so each needs its own filtered query; querying
228
+ // the collection without a categoryType filter (an older bug here) mapped
229
+ // brand/bundle rows onto the wrong /categories/{slug} URL.
230
+ //
231
+ // 🛑 The union is `"category" | "sublisting" | "brand" | "bundle"` — see
232
+ // CategoryType in features/categories/types/index.ts. There is NO "listing"
233
+ // value and there never was. This function used to be called with "listing"
234
+ // for the plain-category case, which matched zero documents, so ~47 category
235
+ // pages were missing from the sitemap for as long as that call existed — with
236
+ // no error, because a zero-row query is indistinguishable from "no categories".
237
+ //
238
+ // It is still only safe to use this helper for discriminators that are actually
239
+ // STORED. A plain listing category OMITS `categoryType` entirely (that is the
240
+ // convention every seeded listing category follows), so no equality can select
241
+ // it — see fetchCategoryUrls below, which inverts the test instead.
183
242
  async function fetchCategoryTypeUrls(baseUrl, categoryType, buildUrl, label) {
184
243
  try {
185
244
  const db = getAdminDb();
@@ -187,10 +246,12 @@ async function fetchCategoryTypeUrls(baseUrl, categoryType, buildUrl, label) {
187
246
  .collection(CATEGORIES_COLLECTION)
188
247
  .where(CATEGORY_FIELDS.CATEGORY_TYPE, "==", categoryType)
189
248
  .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true)
190
- .select(CATEGORY_FIELDS.SLUG, CATEGORY_FIELDS.UPDATED_AT)
249
+ .select(CATEGORY_FIELDS.SLUG, CATEGORY_FIELDS.UPDATED_AT, TEST_DATA_FIELD)
191
250
  .limit(500)
192
251
  .get();
193
- return snap.docs.map((doc) => {
252
+ return snap.docs
253
+ .filter((doc) => !isTestDoc(doc.data()))
254
+ .map((doc) => {
194
255
  const data = doc.data();
195
256
  const slug = data[CATEGORY_FIELDS.SLUG] ?? doc.id;
196
257
  return {
@@ -203,11 +264,57 @@ async function fetchCategoryTypeUrls(baseUrl, categoryType, buildUrl, label) {
203
264
  }
204
265
  catch (err) {
205
266
  void normalizeError(err);
206
- serverLogger.warn(`sitemap: failed to fetch ${label} URLs`, { error: err });
207
- return [];
267
+ return sitemapSectionFailed(label, err);
268
+ }
269
+ }
270
+ /**
271
+ * Plain listing categories — the /categories/{slug} pages.
272
+ *
273
+ * These CANNOT be selected by an equality on `categoryType`, because a plain
274
+ * listing category omits the field entirely rather than storing "category".
275
+ * `.where(CATEGORY_TYPE, "==", <anything>)` therefore excludes every one of
276
+ * them, and a Firestore `!=` would be worse still — it silently drops documents
277
+ * that lack the field, which is exactly the set we want.
278
+ *
279
+ * So: select on `isActive` alone (the one predicate that IS stored on every
280
+ * row) and reject the three discriminated kinds in memory. `categoryType` must
281
+ * stay in the `.select()` list or it comes back undefined and nothing is
282
+ * rejected.
283
+ */
284
+ const NON_LISTING_CATEGORY_TYPES = new Set(["brand", "bundle", "sublisting"]);
285
+ async function fetchCategoryUrls(baseUrl) {
286
+ try {
287
+ const db = getAdminDb();
288
+ const snap = await db
289
+ .collection(CATEGORIES_COLLECTION)
290
+ .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true)
291
+ .select(CATEGORY_FIELDS.SLUG, CATEGORY_FIELDS.UPDATED_AT, CATEGORY_FIELDS.CATEGORY_TYPE, TEST_DATA_FIELD)
292
+ .limit(500)
293
+ .get();
294
+ return snap.docs
295
+ .filter((doc) => {
296
+ const data = doc.data();
297
+ if (isTestDoc(data))
298
+ return false;
299
+ const kind = data[CATEGORY_FIELDS.CATEGORY_TYPE];
300
+ return typeof kind !== "string" || !NON_LISTING_CATEGORY_TYPES.has(kind);
301
+ })
302
+ .map((doc) => {
303
+ const data = doc.data();
304
+ const slug = data[CATEGORY_FIELDS.SLUG] ?? doc.id;
305
+ return {
306
+ url: `${baseUrl}${ROUTES.PUBLIC.CATEGORY_DETAIL(slug)}`,
307
+ lastModified: data[CATEGORY_FIELDS.UPDATED_AT]?.toDate?.() ?? new Date(),
308
+ changeFrequency: "weekly",
309
+ priority: 0.7,
310
+ };
311
+ });
312
+ }
313
+ catch (err) {
314
+ void normalizeError(err);
315
+ return sitemapSectionFailed("category", err);
208
316
  }
209
317
  }
210
- const fetchCategoryUrls = (baseUrl) => fetchCategoryTypeUrls(baseUrl, "listing", (slug) => ROUTES.PUBLIC.CATEGORY_DETAIL(slug), "category");
211
318
  const fetchBrandUrls = (baseUrl) => fetchCategoryTypeUrls(baseUrl, "brand", (slug) => ROUTES.PUBLIC.BRAND_DETAIL(slug), "brand");
212
319
  const fetchBundleUrls = (baseUrl) => fetchCategoryTypeUrls(baseUrl, "bundle", (slug) => ROUTES.PUBLIC.BUNDLE_DETAIL(slug), "bundle");
213
320
  /**
@@ -228,7 +335,9 @@ async function fetchGroupedListingUrls(baseUrl) {
228
335
  }
229
336
  catch (err) {
230
337
  void normalizeError(err);
231
- return [];
338
+ // This catch used to swallow silently — no log at all, so a failure here was
339
+ // indistinguishable from "this site has no grouped listings".
340
+ return sitemapSectionFailed("grouped-listing", err);
232
341
  }
233
342
  }
234
343
  async function fetchBlogPostUrls(baseUrl) {
@@ -237,10 +346,12 @@ async function fetchBlogPostUrls(baseUrl) {
237
346
  const snap = await db
238
347
  .collection(BLOG_POSTS_COLLECTION)
239
348
  .where(BLOG_POST_FIELDS.STATUS, "==", BLOG_POST_FIELDS.STATUS_VALUES.PUBLISHED)
240
- .select(BLOG_POST_FIELDS.SLUG, BLOG_POST_FIELDS.PUBLISHED_AT, BLOG_POST_FIELDS.UPDATED_AT)
349
+ .select(BLOG_POST_FIELDS.SLUG, BLOG_POST_FIELDS.PUBLISHED_AT, BLOG_POST_FIELDS.UPDATED_AT, TEST_DATA_FIELD)
241
350
  .limit(1000)
242
351
  .get();
243
- return snap.docs.map((doc) => {
352
+ return snap.docs
353
+ .filter((doc) => !isTestDoc(doc.data()))
354
+ .map((doc) => {
244
355
  const data = doc.data();
245
356
  const slug = data[BLOG_POST_FIELDS.SLUG] ?? doc.id;
246
357
  const lastModified = data[BLOG_POST_FIELDS.UPDATED_AT]?.toDate?.() ??
@@ -256,8 +367,7 @@ async function fetchBlogPostUrls(baseUrl) {
256
367
  }
257
368
  catch (err) {
258
369
  void normalizeError(err);
259
- serverLogger.warn("sitemap: failed to fetch blog post URLs", { error: err });
260
- return [];
370
+ return sitemapSectionFailed("blog-post", err);
261
371
  }
262
372
  }
263
373
  async function fetchStoreUrls(baseUrl) {
@@ -267,10 +377,12 @@ async function fetchStoreUrls(baseUrl) {
267
377
  .collection(STORE_COLLECTION)
268
378
  .where(STORE_FIELDS.STATUS, "==", STORE_FIELDS.STATUS_VALUES.ACTIVE)
269
379
  .where(STORE_FIELDS.IS_PUBLIC, "==", true)
270
- .select(STORE_FIELDS.STORE_SLUG, STORE_FIELDS.UPDATED_AT)
380
+ .select(STORE_FIELDS.STORE_SLUG, STORE_FIELDS.UPDATED_AT, TEST_DATA_FIELD)
271
381
  .limit(1000)
272
382
  .get();
273
- return snap.docs.map((doc) => {
383
+ return snap.docs
384
+ .filter((doc) => !isTestDoc(doc.data()))
385
+ .map((doc) => {
274
386
  const data = doc.data();
275
387
  const slug = data[STORE_FIELDS.STORE_SLUG] ?? doc.id;
276
388
  return {
@@ -283,8 +395,7 @@ async function fetchStoreUrls(baseUrl) {
283
395
  }
284
396
  catch (err) {
285
397
  void normalizeError(err);
286
- serverLogger.warn("sitemap: failed to fetch store URLs", { error: err });
287
- return [];
398
+ return sitemapSectionFailed("store", err);
288
399
  }
289
400
  }
290
401
  async function fetchScammerUrls(baseUrl) {
@@ -293,10 +404,12 @@ async function fetchScammerUrls(baseUrl) {
293
404
  const snap = await db
294
405
  .collection(SCAMMER_COLLECTION)
295
406
  .where(PRODUCT_FIELDS.STATUS, "==", "verified")
296
- .select("seo", "updatedAt")
407
+ .select("seo", "updatedAt", TEST_DATA_FIELD)
297
408
  .limit(2000)
298
409
  .get();
299
- return snap.docs.map((doc) => {
410
+ return snap.docs
411
+ .filter((doc) => !isTestDoc(doc.data()))
412
+ .map((doc) => {
300
413
  const data = doc.data();
301
414
  const slug = data.seo?.slug ?? doc.id;
302
415
  return {
@@ -309,8 +422,7 @@ async function fetchScammerUrls(baseUrl) {
309
422
  }
310
423
  catch (err) {
311
424
  void normalizeError(err);
312
- serverLogger.warn("sitemap: failed to fetch scammer URLs", { error: err });
313
- return [];
425
+ return sitemapSectionFailed("scammer", err);
314
426
  }
315
427
  }
316
428
  export async function buildSitemap({ baseUrl }) {
@@ -331,8 +443,45 @@ export async function buildSitemap({ baseUrl }) {
331
443
  fetchStoreUrls(baseUrl),
332
444
  fetchScammerUrls(baseUrl),
333
445
  ]);
446
+ const staticUrls = staticPages(baseUrl);
447
+ // Per-section count digest. A section that drops to zero is otherwise
448
+ // indistinguishable from a site that genuinely has none of that thing — which
449
+ // is exactly how `category: 0` survived unnoticed while every other section
450
+ // worked (the query filtered on a `categoryType` value that does not exist).
451
+ //
452
+ // `scripts/deploy.mjs` asserts the same counts against the deployed sitemap
453
+ // and fails the deploy on an empty section; this log is the production-side
454
+ // record for when a section empties out later, between deploys.
455
+ const sections = {
456
+ static: staticUrls.length,
457
+ category: categoryUrls.length,
458
+ brand: brandUrls.length,
459
+ bundle: bundleUrls.length,
460
+ group: groupedListingUrls.length,
461
+ blog: blogUrls.length,
462
+ product: productUrls.length,
463
+ auction: auctionUrls.length,
464
+ preOrder: preOrderUrls.length,
465
+ prizeDraw: prizeDrawUrls.length,
466
+ classified: classifiedUrls.length,
467
+ digitalCode: digitalCodeUrls.length,
468
+ live: liveUrls.length,
469
+ event: eventUrls.length,
470
+ store: storeUrls.length,
471
+ scammer: scammerUrls.length,
472
+ };
473
+ const empty = Object.entries(sections)
474
+ .filter(([, n]) => n === 0)
475
+ .map(([name]) => name);
476
+ const total = Object.values(sections).reduce((a, b) => a + b, 0);
477
+ if (empty.length > 0) {
478
+ serverLogger.warn(`sitemap: ${empty.length} section(s) produced 0 URLs`, { empty, sections, total });
479
+ }
480
+ else {
481
+ serverLogger.info("sitemap: built", { sections, total });
482
+ }
334
483
  return [
335
- ...staticPages(baseUrl),
484
+ ...staticUrls,
336
485
  ...categoryUrls,
337
486
  ...brandUrls,
338
487
  ...bundleUrls,
@@ -27,7 +27,7 @@ import type { StoreDetail, StoreListItem } from "../../../../features/stores/typ
27
27
  */
28
28
  export declare const PUBLIC_STORE_FIELDS: readonly ["id", "storeSlug", "ownerId", "storeName", "storeDescription", "storeCategory", "storeLogoURL", "storeBannerURL", "status", "isPublic", "stats", "bio", "location", "website", "socialLinks", "returnPolicy", "shippingPolicy", "isVacationMode", "vacationMessage", "createdAt"];
29
29
  /** Everything else, with the reason it stays server-side. */
30
- export declare const PRIVATE_STORE_FIELDS: readonly ["updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt"];
30
+ export declare const PRIVATE_STORE_FIELDS: readonly ["searchTxt", "updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt"];
31
31
  /**
32
32
  * Accepted input: a `StoreDocument` (the common case) or a loosely-typed
33
33
  * Firestore row. The legacy top-level counters below predate `stats` and are