@mohasinac/appkit 4.64.1 → 4.65.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 (163) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.js +18 -1
  3. package/dist/_internal/server/features/search/index-build/sources.js +27 -12
  4. package/dist/_internal/server/features/seo/sitemap.js +78 -12
  5. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  6. package/dist/_internal/server/features/site-settings/adapters.js +16 -1
  7. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  8. package/dist/_internal/server/features/stores/adapters.js +14 -0
  9. package/dist/_internal/server/features/stores/tabs.d.ts +19 -15
  10. package/dist/_internal/server/features/stores/tabs.js +20 -21
  11. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +9 -4
  12. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +22 -0
  13. package/dist/_internal/server/jobs/core/countersReconcile.js +0 -1
  14. package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +1 -1
  15. package/dist/_internal/shared/features/site-settings/types.d.ts +10 -2
  16. package/dist/_internal/shared/features/sticker-trace/trace/segment.js +13 -2
  17. package/dist/_internal/shared/listing-types/feature-flags.d.ts +54 -0
  18. package/dist/_internal/shared/listing-types/feature-flags.js +72 -0
  19. package/dist/client.d.ts +5 -2
  20. package/dist/client.js +8 -1
  21. package/dist/constants/field-names.d.ts +1 -0
  22. package/dist/constants/field-names.js +14 -0
  23. package/dist/features/account/api/[userId]/addresses/route.js +1 -5
  24. package/dist/features/addresses/repository/addresses.repository.js +1 -1
  25. package/dist/features/admin/api/bids/route.js +1 -5
  26. package/dist/features/admin/api/coupons/route.js +1 -5
  27. package/dist/features/admin/api/products/route.js +1 -5
  28. package/dist/features/admin/api/reviews/route.js +1 -5
  29. package/dist/features/admin/components/AdminCarouselEditorView.js +0 -1
  30. package/dist/features/admin/components/AdminFeatureEditorView.js +1 -2
  31. package/dist/features/admin/components/AdminGuideHubView.js +1 -1
  32. package/dist/features/admin/components/AdminProductEditorView.js +4 -1
  33. package/dist/features/admin/components/AdminSiteSettingsView.js +19 -3
  34. package/dist/features/admin/components/OwnerQuickActions.d.ts +7 -1
  35. package/dist/features/admin/components/OwnerQuickActions.js +1 -1
  36. package/dist/features/admin/schemas/admin-editor-forms.js +0 -1
  37. package/dist/features/admin/schemas/firestore.d.ts +52 -0
  38. package/dist/features/admin/schemas/firestore.js +20 -0
  39. package/dist/features/auctions/api/route.js +1 -8
  40. package/dist/features/auctions/components/AuctionDetailPageView.js +1 -7
  41. package/dist/features/auth/hooks/useAuth.js +1 -1
  42. package/dist/features/before-after/api/route.js +1 -5
  43. package/dist/features/blog/api/route.js +1 -8
  44. package/dist/features/blog/components/BlogIndexPageView.d.ts +2 -3
  45. package/dist/features/blog/components/BlogIndexPageView.js +6 -9
  46. package/dist/features/cart/actions/cart-actions.js +1 -1
  47. package/dist/features/cart/api/route.js +1 -5
  48. package/dist/features/cart/utils/guest-cart.js +1 -5
  49. package/dist/features/catalogue/repository/catalogue.repository.js +1 -1
  50. package/dist/features/categories/api/route.js +5 -23
  51. package/dist/features/categories/components/BundlesListView.d.ts +2 -3
  52. package/dist/features/categories/components/BundlesListView.js +2 -5
  53. package/dist/features/categories/components/CategoriesIndexListing.js +47 -5
  54. package/dist/features/categories/components/CategoriesIndexPageView.d.ts +2 -3
  55. package/dist/features/categories/components/CategoriesIndexPageView.js +0 -32
  56. package/dist/features/categories/constants/category-tabs.d.ts +1 -1
  57. package/dist/features/categories/constants/category-tabs.js +7 -0
  58. package/dist/features/categories/hooks/useCategories.d.ts +6 -0
  59. package/dist/features/categories/hooks/useCategories.js +73 -2
  60. package/dist/features/categories/types/index.d.ts +1 -1
  61. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -8
  62. package/dist/features/collections/api/route.js +1 -5
  63. package/dist/features/consultation/api/route.js +1 -5
  64. package/dist/features/contact/email.js +2 -5
  65. package/dist/features/corporate/api/route.js +1 -5
  66. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +1 -7
  67. package/dist/features/events/components/AdminEventEditorView.js +1 -1
  68. package/dist/features/events/components/EventsListPageView.d.ts +2 -3
  69. package/dist/features/events/components/EventsListPageView.js +8 -11
  70. package/dist/features/faq/constants/help-guides.d.ts +8 -5
  71. package/dist/features/faq/constants/help-guides.js +9 -5
  72. package/dist/features/filters/AsyncFacetSection.js +2 -2
  73. package/dist/features/filters/FilterFacetSection.js +5 -5
  74. package/dist/features/filters/RangeFilter.js +2 -2
  75. package/dist/features/filters/SwitchFilter.js +2 -2
  76. package/dist/features/filters/filterUtils.d.ts +0 -2
  77. package/dist/features/filters/filterUtils.js +0 -4
  78. package/dist/features/filters/index.d.ts +1 -1
  79. package/dist/features/filters/index.js +1 -1
  80. package/dist/features/forms/Checkbox.js +4 -3
  81. package/dist/features/forms/Input.js +5 -4
  82. package/dist/features/forms/Radio.js +6 -5
  83. package/dist/features/forms/Select.js +3 -2
  84. package/dist/features/forms/Slider.js +3 -2
  85. package/dist/features/forms/Textarea.js +3 -2
  86. package/dist/features/forms/Toggle.js +3 -3
  87. package/dist/features/forms/index.d.ts +1 -1
  88. package/dist/features/forms/index.js +1 -1
  89. package/dist/features/forms/utils.d.ts +0 -2
  90. package/dist/features/forms/utils.js +0 -4
  91. package/dist/features/grouped/schemas/grouped-listing-form.d.ts +6 -6
  92. package/dist/features/grouped/schemas/index.d.ts +2 -2
  93. package/dist/features/history/utils/guest-history.js +1 -5
  94. package/dist/features/homepage/components/CustomCardsSection.js +8 -2
  95. package/dist/features/homepage/components/FeaturedBundlesSection.d.ts +6 -1
  96. package/dist/features/homepage/components/FeaturedBundlesSection.js +1 -1
  97. package/dist/features/layout/AppLayoutShell.d.ts +23 -1
  98. package/dist/features/layout/AppLayoutShell.js +13 -12
  99. package/dist/features/layout/BottomNavItem.d.ts +9 -1
  100. package/dist/features/layout/BottomNavItem.js +18 -4
  101. package/dist/features/layout/index.d.ts +1 -1
  102. package/dist/features/live/components/LiveItemDetailPageView.js +1 -7
  103. package/dist/features/media/MediaLightbox.js +3 -5
  104. package/dist/features/orders/components/OrderCompletionPanel.d.ts +1 -1
  105. package/dist/features/orders/components/OrderCompletionPanel.js +1 -1
  106. package/dist/features/orders/components/RaiseDisputeForm.js +1 -1
  107. package/dist/features/orders/components/RefundRequestView.js +1 -1
  108. package/dist/features/parts/schemas/firestore.d.ts +274 -0
  109. package/dist/features/parts/schemas/firestore.js +19 -0
  110. package/dist/features/payments/repository/saved-payment-methods.repository.js +1 -1
  111. package/dist/features/pre-orders/api/admin/route.js +1 -5
  112. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -7
  113. package/dist/features/products/api/route.js +1 -3
  114. package/dist/features/products/components/CustomSectionTabContent.js +0 -5
  115. package/dist/features/products/components/GroupMemberPicker.js +2 -2
  116. package/dist/features/products/components/GroupSettingsPanel.js +1 -1
  117. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -7
  118. package/dist/features/products/components/PrizeDrawEntryActions.d.ts +1 -1
  119. package/dist/features/products/components/PrizeDrawEntryActions.js +1 -1
  120. package/dist/features/products/components/ProductDetailPageView.js +1 -7
  121. package/dist/features/products/repository/products.repository.js +0 -1
  122. package/dist/features/promotions/api/route.js +1 -8
  123. package/dist/features/reviews/components/ReviewsIndexPageView.d.ts +2 -3
  124. package/dist/features/reviews/components/ReviewsIndexPageView.js +9 -12
  125. package/dist/features/seller/api/coupons/route.js +1 -5
  126. package/dist/features/seller/api/products/route.js +1 -5
  127. package/dist/features/seller/components/SellerProductShell.js +1 -2
  128. package/dist/features/seller/hooks/useSellerListingData.js +0 -30
  129. package/dist/features/stores/api/[storeSlug]/auctions/route.js +1 -10
  130. package/dist/features/stores/api/[storeSlug]/products/route.js +1 -10
  131. package/dist/features/stores/components/InteractiveStoreCard.js +9 -1
  132. package/dist/features/stores/components/StoreGuideHubView.js +1 -1
  133. package/dist/features/stores/components/StoresIndexPageView.d.ts +2 -3
  134. package/dist/features/stores/components/StoresIndexPageView.js +6 -9
  135. package/dist/features/stores/schemas/firestore.d.ts +27 -0
  136. package/dist/features/stores/schemas/firestore.js +2 -0
  137. package/dist/features/wishlist/utils/guest-wishlist.js +1 -5
  138. package/dist/index.d.ts +2 -1
  139. package/dist/index.js +7 -1
  140. package/dist/providers/db-firebase/admin-app-lite.js +14 -2
  141. package/dist/providers/db-firebase/admin.js +8 -2
  142. package/dist/react/hooks/useBrowsePageFlags.d.ts +26 -0
  143. package/dist/react/hooks/useBrowsePageFlags.js +18 -0
  144. package/dist/schemas/registry.d.ts +4 -4
  145. package/dist/security/csp.d.ts +32 -2
  146. package/dist/security/csp.js +32 -2
  147. package/dist/styles.css +1 -1
  148. package/dist/tailwind-utilities.css +1 -1
  149. package/dist/ui/components/Button.js +1 -1
  150. package/dist/ui/components/Chart.js +0 -1
  151. package/dist/ui/rich-text/RichText.js +1 -3
  152. package/dist/ui/rich-text/RichTextRenderer.js +1 -3
  153. package/dist/utils/browser-storage.d.ts +14 -0
  154. package/dist/utils/browser-storage.js +18 -0
  155. package/dist/utils/classnames.d.ts +19 -0
  156. package/dist/utils/classnames.js +21 -0
  157. package/dist/utils/search-params.d.ts +44 -0
  158. package/dist/utils/search-params.js +46 -0
  159. package/dist/utils/sieve-builder.d.ts +13 -0
  160. package/dist/utils/sieve-builder.js +22 -0
  161. package/dist/utils/string.formatter.d.ts +12 -0
  162. package/dist/utils/string.formatter.js +17 -0
  163. package/package.json +2 -2
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.64.1",
4
- "head": "e32e109a86e927632104a20ca85625cb9210ba77",
3
+ "version": "4.65.0",
4
+ "head": "3061d7d6d7b6dc89f57b2fa015b3e2f8243099a1",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-24T02:57:20.572Z"
7
+ "builtAt": "2026-10-25T08:53:27.001Z"
8
8
  }
@@ -1145,7 +1145,7 @@ async function createOrderForGroup(group, orderType, ctx) {
1145
1145
  */
1146
1146
  const pointsOptedIn = useAllPoints === true;
1147
1147
  const pointsAccepted = pointsOptedIn && acceptsPoints;
1148
- const { pointsApplied, amountDue } = resolvePointsTender({
1148
+ const { pointsApplied } = resolvePointsTender({
1149
1149
  orderTotal,
1150
1150
  balance: pointsAccepted ? pointsBudget.remaining : 0,
1151
1151
  accepted: pointsAccepted,
@@ -3496,6 +3496,23 @@ export async function verifyAndPlaceGatewayOrderAction(input) {
3496
3496
  phonepeOrderId: gatewayOrder.gatewayOrderId,
3497
3497
  transactionId: gatewayOrder.transactionId,
3498
3498
  shippingAddress,
3499
+ /*
3500
+ * 🛑 Was MISSING until 2026-10-24, and nothing reported it. The snapshot was
3501
+ * computed ~490 lines above with a comment explaining why it must be revealed
3502
+ * first — and then never passed, so every order settled through the gateway
3503
+ * path lost its immutable delivery copy while the COD/UPI path (which calls
3504
+ * `createOrderForGroup` and does pass it) kept one.
3505
+ *
3506
+ * Two consequences, both silent: the invoice address was not frozen at
3507
+ * purchase time (Part 14's whole point), and `placeOfSupply` fell through to
3508
+ * `buyerState` — so the CGST/SGST-vs-IGST split was decided by the buyer's
3509
+ * profile state rather than the address they actually shipped to.
3510
+ *
3511
+ * Root Cause #39's shape exactly: two create paths, one applies a transform
3512
+ * the other does not. Found as an unused-variable warning, which is the only
3513
+ * signal a dropped value ever gives.
3514
+ */
3515
+ deliverySnapshot,
3499
3516
  // For the CGST/SGST vs IGST split. The TOTAL does not depend on it
3500
3517
  // (see createPhonePeGroupOrder), which is what let the pre-payment
3501
3518
  // amount be computed before this address was known.
@@ -39,6 +39,7 @@
39
39
  * @tag sideEffects:none
40
40
  */
41
41
  import { pluginFor } from "../../../../shared/listing-types/_registry.js";
42
+ import { ROUTES } from "../../../../../next/routing/route-map.js";
42
43
  // Defined here, not in `_registry` — imported from the DEFINING module
43
44
  // (Root Cause #18).
44
45
  import { ALL_LISTING_TYPES } from "../../../../shared/listing-types/feature-flags.js";
@@ -82,23 +83,37 @@ export const INDEX_TYPES = [
82
83
  weight: t === "standard" ? 1.0 : 0.98,
83
84
  };
84
85
  }),
85
- { key: "category", hrefTemplate: "/categories/{s}", moreHref: "/categories", weight: 0.95 },
86
- { key: "brand", hrefTemplate: "/brands/{s}", moreHref: "/brands", weight: 0.9 },
87
- { key: "part", hrefTemplate: "/parts/{s}", moreHref: "/parts", weight: 0.9 },
88
- { key: "model", hrefTemplate: "/categories/{s}", moreHref: "/categories", weight: 0.85 },
89
- { key: "store", hrefTemplate: "/stores/{s}", moreHref: "/stores", weight: 0.8 },
90
- { key: "blog", hrefTemplate: "/blog/{s}", moreHref: "/blog", weight: 0.7 },
91
- { key: "event", hrefTemplate: "/events/{s}", moreHref: "/events", weight: 0.7 },
92
- { key: "faq", hrefTemplate: "/faqs/{s}", moreHref: "/faqs", weight: 0.6 },
93
- { key: "review", hrefTemplate: "/reviews/{s}", moreHref: "/reviews", weight: 0.5 },
94
- { key: "group", hrefTemplate: "/groups/{s}", moreHref: "/products", weight: 0.5 },
95
- { key: "scammer", hrefTemplate: "/scams/{s}", moreHref: "/scams", weight: 0.5 },
86
+ /*
87
+ * 🛑 Built from ROUTES, exactly as the listing-type entries above are built
88
+ * from `pluginFor`. These ten were hand-written path literals until
89
+ * 2026-10-24 — sitting directly beside a block whose own comment says "One
90
+ * source of truth, no second mapping" and cites Root Cause #48 for the
91
+ * version of this bug that shipped. A renamed segment would have moved every
92
+ * real page and left the search index pointing at the old one, with each
93
+ * result rendering correctly and navigating to a 404.
94
+ *
95
+ * `{s}` is the placeholder the template engine substitutes, so the builder
96
+ * functions are handed it directly — the same trick `detailRoute("{s}")` uses
97
+ * above rather than re-deriving the shape.
98
+ */
99
+ { key: "category", hrefTemplate: ROUTES.PUBLIC.CATEGORY_DETAIL("{s}"), moreHref: String(ROUTES.PUBLIC.CATEGORIES), weight: 0.95 },
100
+ { key: "brand", hrefTemplate: ROUTES.PUBLIC.BRAND_DETAIL("{s}"), moreHref: String(ROUTES.PUBLIC.BRANDS), weight: 0.9 },
101
+ { key: "part", hrefTemplate: ROUTES.PUBLIC.PART_CLASSIFICATION("{s}"), moreHref: String(ROUTES.PUBLIC.PARTS), weight: 0.9 },
102
+ /* A model IS a category row, which is why it shares the category route. */
103
+ { key: "model", hrefTemplate: ROUTES.PUBLIC.CATEGORY_DETAIL("{s}"), moreHref: String(ROUTES.PUBLIC.CATEGORIES), weight: 0.85 },
104
+ { key: "store", hrefTemplate: ROUTES.PUBLIC.STORE_DETAIL("{s}"), moreHref: String(ROUTES.PUBLIC.STORES), weight: 0.8 },
105
+ { key: "blog", hrefTemplate: ROUTES.BLOG.ARTICLE("{s}"), moreHref: String(ROUTES.BLOG.LIST), weight: 0.7 },
106
+ { key: "event", hrefTemplate: ROUTES.PUBLIC.EVENT_DETAIL("{s}"), moreHref: String(ROUTES.PUBLIC.EVENTS), weight: 0.7 },
107
+ { key: "faq", hrefTemplate: ROUTES.PUBLIC.FAQ_CATEGORY("{s}"), moreHref: String(ROUTES.PUBLIC.FAQS), weight: 0.6 },
108
+ { key: "review", hrefTemplate: ROUTES.PUBLIC.REVIEW_DETAIL("{s}"), moreHref: String(ROUTES.PUBLIC.REVIEWS), weight: 0.5 },
109
+ { key: "group", hrefTemplate: ROUTES.PUBLIC.GROUP_DETAIL("{s}"), moreHref: String(ROUTES.PUBLIC.PRODUCTS), weight: 0.5 },
110
+ { key: "scammer", hrefTemplate: ROUTES.PUBLIC.SCAM_DETAIL("{s}"), moreHref: String(ROUTES.PUBLIC.SCAMS), weight: 0.5 },
96
111
  /* `/promotions` has no per-coupon page, so `{s}` is absent on purpose and
97
112
  * `hrefFor`'s replace is a no-op. Eleven rows all landing on one page would
98
113
  * be noise in the federated GROUP (which is why `search-sources.ts` omits
99
114
  * coupons), but in the index it is the difference between a code being
100
115
  * findable and not. */
101
- { key: "coupon", hrefTemplate: "/promotions", moreHref: "/promotions", weight: 0.4 },
116
+ { key: "coupon", hrefTemplate: String(ROUTES.PUBLIC.PROMOTIONS), moreHref: String(ROUTES.PUBLIC.PROMOTIONS), weight: 0.4 },
102
117
  ];
103
118
  const facet = (pairs) => {
104
119
  const parts = pairs
@@ -14,6 +14,9 @@ import { PART_GLOSSARY_COLLECTION, ALL_PART_SLOTS } from "../../../../features/p
14
14
  import { serverLogger } from "../../../../monitoring/server-logger.js";
15
15
  import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
16
16
  import { PRODUCT_DETAIL_LISTING_TYPES } from "./product-detail-types.js";
17
+ import { safeRead } from "../../../../errors/safe-read.js";
18
+ import { siteSettingsRepository } from "../../../../repositories/index.js";
19
+ import { ALL_BROWSE_PAGES, browsePageForPath, isBrowsePageEnabled, } from "../../../shared/listing-types/feature-flags.js";
17
20
  // Product field strings — matches consumer field-names.ts
18
21
  const PRODUCT_STATUS = "status";
19
22
  const PRODUCT_STATUS_PUBLISHED = "published";
@@ -618,17 +621,43 @@ export async function buildSitemap({ baseUrl }) {
618
621
  bey: beyUrls.length,
619
622
  part: partUrls.length,
620
623
  };
621
- const empty = Object.entries(sections)
622
- .filter(([, n]) => n === 0)
623
- .map(([name]) => name);
624
- const total = Object.values(sections).reduce((a, b) => a + b, 0);
625
- if (empty.length > 0) {
626
- serverLogger.warn(`sitemap: ${empty.length} section(s) produced 0 URLs`, { empty, sections, total });
627
- }
628
- else {
629
- serverLogger.info("sitemap: built", { sections, total });
630
- }
631
- return [
624
+ /*
625
+ * Drop every URL belonging to a CLOSED browse section.
626
+ *
627
+ * 🛑 A single filter over the assembled list, not a condition threaded
628
+ * through the eighteen fetchers above. Section URLs are emitted from several
629
+ * of them plus `staticPages`, so gating from the inside is five edits and
630
+ * five chances to miss the sixth — and `browsePageForPath` is the same rule
631
+ * the navbar and the footer use, so the sitemap cannot end up disagreeing
632
+ * with the chrome about which sections exist.
633
+ *
634
+ * Advertising a URL that answers 404 is worse than omitting it: it is Root
635
+ * Cause #81's host/sitemap disagreement in miniature, and it spends crawl
636
+ * budget on pages we have deliberately withdrawn.
637
+ *
638
+ * 🛑 Applied BEFORE the per-section digest below. The digest is what
639
+ * `scripts/deploy.mjs` asserts against, so counting pre-filter would log
640
+ * URLs the response does not carry.
641
+ */
642
+ const settings = await safeRead(() => siteSettingsRepository.findById("global"), {
643
+ route: "sitemap.xml",
644
+ key: "sitemap.siteSettings",
645
+ // Fails OPEN: an unreadable settings document is not evidence that a
646
+ // section was closed, and silently dropping the catalogue from the sitemap
647
+ // on a transient Firestore error is the expensive direction of this guess.
648
+ fallback: null,
649
+ });
650
+ const closed = new Set(ALL_BROWSE_PAGES.filter((p) => !isBrowsePageEnabled(p, settings)));
651
+ const keep = (entry) => {
652
+ if (closed.size === 0)
653
+ return true;
654
+ const path = entry.url.startsWith("http")
655
+ ? new URL(entry.url).pathname
656
+ : entry.url;
657
+ const page = browsePageForPath(path);
658
+ return page === null || !closed.has(page);
659
+ };
660
+ const all = [
632
661
  ...staticUrls,
633
662
  ...categoryUrls,
634
663
  ...brandUrls,
@@ -648,5 +677,42 @@ export async function buildSitemap({ baseUrl }) {
648
677
  ...scammerUrls,
649
678
  ...beyUrls,
650
679
  ...partUrls,
651
- ];
680
+ ].filter(keep);
681
+ /*
682
+ * Recount AFTER the filter, and exclude a deliberately-closed section from
683
+ * the "produced 0 URLs" alarm.
684
+ *
685
+ * 🛑 Without this the feature would make its own monitoring cry wolf: the
686
+ * warning exists because `category: 0` once survived unnoticed for months,
687
+ * and a warning that fires every build for a reason everyone knows about is
688
+ * how the next real zero gets ignored. "Closed on purpose" and "broken" must
689
+ * not look the same in the log.
690
+ */
691
+ const sectionPage = {
692
+ category: "categories",
693
+ brand: "brands",
694
+ franchise: "franchise",
695
+ store: "stores",
696
+ };
697
+ for (const name of Object.keys(sections)) {
698
+ const page = sectionPage[name];
699
+ if (page && closed.has(page))
700
+ sections[name] = 0;
701
+ }
702
+ const empty = Object.entries(sections)
703
+ .filter(([name, n]) => n === 0 && !closed.has(sectionPage[name]))
704
+ .map(([name]) => name);
705
+ const total = all.length;
706
+ if (closed.size > 0) {
707
+ serverLogger.info("sitemap: browse section(s) closed, URLs withheld", {
708
+ closed: [...closed],
709
+ });
710
+ }
711
+ if (empty.length > 0) {
712
+ serverLogger.warn(`sitemap: ${empty.length} section(s) produced 0 URLs`, { empty, sections, total });
713
+ }
714
+ else {
715
+ serverLogger.info("sitemap: built", { sections, total });
716
+ }
717
+ return all;
652
718
  }
@@ -39,7 +39,7 @@ import type { PublicSiteSettings } from "../../../shared/features/site-settings/
39
39
  */
40
40
  export declare const PUBLIC_SITE_SETTINGS_FIELDS: readonly ["contact", "payment", "listings", "notificationChannels", "announcementBar", "support", "navConfig", "actionConfig", "background", "watermark", "disabledRoutes", "chat", "search"];
41
41
  /** Everything else, with the reason it stays server-side. */
42
- export declare const PRIVATE_SITE_SETTINGS_FIELDS: readonly ["rateChanges", "id", "createdAt", "updatedAt", "credentials", "emailSettings", "legalPages", "commissions", "emi", "gst", "laborRate", "adSettings", "integrations", "platformLimits", "auctionConfig", "siteName", "tagline", "motto", "logo", "favicon", "seo", "socialLinks", "features", "shipping", "returns", "faq", "aboutContent", "navbarConfig", "footerConfig", "theme", "featuredResults", "messaging"];
42
+ export declare const PRIVATE_SITE_SETTINGS_FIELDS: readonly ["rateChanges", "id", "createdAt", "updatedAt", "credentials", "emailSettings", "legalPages", "commissions", "emi", "gst", "laborRate", "adSettings", "integrations", "platformLimits", "auctionConfig", "siteName", "tagline", "motto", "logo", "favicon", "seo", "socialLinks", "features", "shipping", "returns", "faq", "aboutContent", "navbarConfig", "footerConfig", "theme", "featuredResults", "messaging", "whatsappFeed"];
43
43
  export interface ToPublicSiteSettingsOptions {
44
44
  /**
45
45
  * The resolved marker → wordmark → text watermark chain. Injected because
@@ -6,7 +6,7 @@
6
6
  export const PUBLIC_SITE_SETTINGS_FIELDS = [
7
7
  "contact", // user/orders/[id]/payment — contact.whatsappNumber (narrowed: no address/upiVpa)
8
8
  "payment", // CheckoutRouteClient — payment.otpCheckoutThreshold (narrowed)
9
- "listings", // useListingTypeFlags — narrowed to listings.listingTypes; categoryTypes has no client reader
9
+ "listings", // useListingTypeFlags + LayoutShellClient nav filter — narrowed to listings.{listingTypes,browsePages}; categoryTypes has no client reader
10
10
  "notificationChannels", // NARROWED: only {email,whatsapp,sms}.enabled — see the projection below
11
11
  "announcementBar", // the homepage banner (AdminSiteSettingsView edits it)
12
12
  /*
@@ -118,6 +118,18 @@ export const PRIVATE_SITE_SETTINGS_FIELDS = [
118
118
  // checkbox, which disables itself when email is off — is behind the admin
119
119
  // API, which returns the full document.
120
120
  "messaging",
121
+ /*
122
+ * The WhatsApp catalogue feed kill switch. Read ONLY by
123
+ * `/whatsapp/{storeSlug}/feed.csv`, which is a server route doing its own
124
+ * settings read — the same shape as `auctionConfig` above.
125
+ *
126
+ * 🛑 Deliberately NOT public, unlike its sibling `search`. That one is public
127
+ * because its reader is a GUEST's search box, so a server-only flag could not
128
+ * reach the visitor it governs. This flag's only reader is Meta's crawler
129
+ * fetching a CSV; no browser consults it, and publishing "catalogue feeds are
130
+ * currently off platform-wide" is operational detail with no client to serve.
131
+ */
132
+ "whatsappFeed",
121
133
  ];
122
134
  export function toPublicSiteSettings(doc, opts) {
123
135
  return {
@@ -137,6 +149,9 @@ export function toPublicSiteSettings(doc, opts) {
137
149
  },
138
150
  listings: {
139
151
  listingTypes: doc.listings?.listingTypes,
152
+ // Read by LayoutShellClient to drop nav/footer links into a closed
153
+ // section. `categoryTypes` is still withheld — it has no client reader.
154
+ browsePages: doc.listings?.browsePages,
140
155
  },
141
156
  /*
142
157
  * 🛑 Narrowed to the three booleans a client reads, not the group by
@@ -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", "supportsShippingProtection", "supportsSaleProtection", "allowSharedPurchase", "sharedPurchaseHandlingFee", "sharedPurchaseExtraShippingPerDestination", "isVerified", "sellerTier", "standing", "createdAt"];
29
29
  /** Everything else, with the reason it stays server-side. */
30
- export declare const PRIVATE_STORE_FIELDS: readonly ["whatsappNotifySubscribed", "trustedSeller", "acceptsPoints", "searchTxt", "whatsappNotifySubscribed", "updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "tierLockedByAdmin", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "pricesIncludeGst", "shippingPricing", "returnsDirectToSeller", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt", "gstin", "legalName", "registeredAddress"];
30
+ export declare const PRIVATE_STORE_FIELDS: readonly ["whatsappNotifySubscribed", "whatsappFeedEnabled", "trustedSeller", "acceptsPoints", "searchTxt", "whatsappNotifySubscribed", "updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "tierLockedByAdmin", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "pricesIncludeGst", "shippingPricing", "returnsDirectToSeller", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt", "gstin", "legalName", "registeredAddress"];
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
@@ -104,6 +104,20 @@ export const PRIVATE_STORE_FIELDS = [
104
104
  * someone can name the client that reads it.
105
105
  */
106
106
  "whatsappNotifySubscribed",
107
+ /*
108
+ * 🛑 `whatsappFeedEnabled` — a seller's own DISTRIBUTION PLUMBING.
109
+ *
110
+ * Whether this store currently publishes `/whatsapp/{slug}/feed.csv` with
111
+ * rows in it. Absent means enabled, so the field is only ever present when a
112
+ * seller (or an admin) has switched the feed off.
113
+ *
114
+ * No public reader, which is this file's bar on its own. Beyond that, a buyer
115
+ * learns nothing from it — it says nothing about the goods, the price or the
116
+ * dispatch record — while publishing it would advertise which stores are
117
+ * reachable on WhatsApp at all, which is the same disclosure
118
+ * `whatsappNotifySubscribed` directly above is private to avoid.
119
+ */
120
+ "whatsappFeedEnabled",
107
121
  /*
108
122
  * 🛑 `trustedSeller` — OUR RISK POSTURE, not a badge.
109
123
  *
@@ -24,22 +24,26 @@ export declare function resolveStoreTabs(storeSlug: string, storeId: string | un
24
24
  /**
25
25
  * Where the bare `/stores/[slug]` route should land.
26
26
  *
27
- * Order: **About when the store has written one**, else the first
28
- * listing-type tab holding anything, else Coupons, else Reviews, else About.
29
- * `about` is always present in `tabs`, so this never falls through to nothing.
27
+ * Order: the first listing-type tab holding anything, else Coupons, else
28
+ * Reviews, else About. `about` is always present in `tabs`, so this never
29
+ * falls through to nothing.
30
30
  *
31
- * 🛑 About was promoted to first on 2026-10-21, deliberately. Previously the
32
- * richest listing tab always won, so any stocked store sent every visitor
33
- * straight to a filtered product grid — the storefront read as a search
34
- * result, and a seller who had written an introduction was the only person who
35
- * ever saw it.
31
+ * 🛑 About was promoted to FIRST on 2026-10-21 and REVERTED on 2026-10-24.
32
+ * The argument for it was that a stocked storefront opening on a filtered
33
+ * product grid "reads as a search result". What it actually produced is worse
34
+ * and was reported as a bug: `store-letitrip-official` carries a
35
+ * `storeDescription`, so every visit to `/stores/store-letitrip-official`
36
+ * 307'd to `/about` — a page with no merchandise on it — and the shop read as
37
+ * having **no products at all**. Measured the day of the revert: the store
38
+ * holds 22 published listings, its `/products` tab renders 12 of them and its
39
+ * WhatsApp feed emits all 22, while the landing page showed none of it.
36
40
  *
37
- * It is gated on the store ACTUALLY having About content rather than applied
38
- * unconditionally: landing on an empty About page would be strictly worse than
39
- * landing on products, so the old behaviour remains the fallback. The caller
40
- * supplies the flag because this module takes a `storeId`, not the document,
41
- * and giving it store-shape knowledge it does not otherwise need would make it
42
- * a second place that decides what "has an About" means.
41
+ * A storefront's job is to show stock. An introduction is a tab, and the tab
42
+ * is right there in the nav bar.
43
+ *
44
+ * 🛑 Do NOT re-add a `hasAboutContent` parameter to re-litigate this. It was
45
+ * the mechanism of the regression, not a safeguard against it: having written
46
+ * a description is not evidence that a shopper wants to read one.
43
47
  */
44
- export declare function getDefaultStoreTabPath(storeSlug: string, storeId: string | undefined, settings: SiteSettingsSnapshot, hasAboutContent?: boolean): Promise<string>;
48
+ export declare function getDefaultStoreTabPath(storeSlug: string, storeId: string | undefined, settings: SiteSettingsSnapshot): Promise<string>;
45
49
  export {};
@@ -27,7 +27,7 @@ import { ROUTES } from "../../../../next/routing/route-map.js";
27
27
  import { STORE_PAGE_TABS } from "../../../../features/products/constants/listing-tabs.js";
28
28
  import { isListingTypeEnabled, isCategoryTypeEnabled } from "../../../shared/listing-types/feature-flags.js";
29
29
  import { pluginFor } from "../../../shared/listing-types/_registry.js";
30
- import { listingTabCounts, } from "../products/listing-tab-counts.js";
30
+ import { listingTabCounts } from "../products/listing-tab-counts.js";
31
31
  function tabLabel(base, count) {
32
32
  if (!count)
33
33
  return base;
@@ -116,29 +116,28 @@ export async function resolveStoreTabs(storeSlug, storeId, settings) {
116
116
  /**
117
117
  * Where the bare `/stores/[slug]` route should land.
118
118
  *
119
- * Order: **About when the store has written one**, else the first
120
- * listing-type tab holding anything, else Coupons, else Reviews, else About.
121
- * `about` is always present in `tabs`, so this never falls through to nothing.
119
+ * Order: the first listing-type tab holding anything, else Coupons, else
120
+ * Reviews, else About. `about` is always present in `tabs`, so this never
121
+ * falls through to nothing.
122
122
  *
123
- * 🛑 About was promoted to first on 2026-10-21, deliberately. Previously the
124
- * richest listing tab always won, so any stocked store sent every visitor
125
- * straight to a filtered product grid — the storefront read as a search
126
- * result, and a seller who had written an introduction was the only person who
127
- * ever saw it.
123
+ * 🛑 About was promoted to FIRST on 2026-10-21 and REVERTED on 2026-10-24.
124
+ * The argument for it was that a stocked storefront opening on a filtered
125
+ * product grid "reads as a search result". What it actually produced is worse
126
+ * and was reported as a bug: `store-letitrip-official` carries a
127
+ * `storeDescription`, so every visit to `/stores/store-letitrip-official`
128
+ * 307'd to `/about` — a page with no merchandise on it — and the shop read as
129
+ * having **no products at all**. Measured the day of the revert: the store
130
+ * holds 22 published listings, its `/products` tab renders 12 of them and its
131
+ * WhatsApp feed emits all 22, while the landing page showed none of it.
128
132
  *
129
- * It is gated on the store ACTUALLY having About content rather than applied
130
- * unconditionally: landing on an empty About page would be strictly worse than
131
- * landing on products, so the old behaviour remains the fallback. The caller
132
- * supplies the flag because this module takes a `storeId`, not the document,
133
- * and giving it store-shape knowledge it does not otherwise need would make it
134
- * a second place that decides what "has an About" means.
133
+ * A storefront's job is to show stock. An introduction is a tab, and the tab
134
+ * is right there in the nav bar.
135
+ *
136
+ * 🛑 Do NOT re-add a `hasAboutContent` parameter to re-litigate this. It was
137
+ * the mechanism of the regression, not a safeguard against it: having written
138
+ * a description is not evidence that a shopper wants to read one.
135
139
  */
136
- export async function getDefaultStoreTabPath(storeSlug, storeId, settings, hasAboutContent) {
140
+ export async function getDefaultStoreTabPath(storeSlug, storeId, settings) {
137
141
  const { dropdownTabs, tabs } = await resolveStoreTabs(storeSlug, storeId, settings);
138
- if (hasAboutContent) {
139
- const about = tabs.find((t) => t.value === "about");
140
- if (about)
141
- return about.href;
142
- }
143
142
  return (dropdownTabs[0] ?? tabs[0]).href;
144
143
  }
@@ -54,7 +54,6 @@ const QUERY_LIMIT = 500;
54
54
  * uses.
55
55
  */
56
56
  const ORDERS_COLLECTION = "orders";
57
- const REVIEWS_COLLECTION = "reviews";
58
57
  const PAYOUTS_COLLECTION = "payouts";
59
58
  const REPORTS_COLLECTION = "reports";
60
59
  const USERS_COLLECTION = "users";
@@ -142,9 +141,15 @@ function buildMetricCache(ctx, bucket, now, onSaturated) {
142
141
  const snap = await build().limit(QUERY_LIMIT).get();
143
142
  return { value: snap.size, saturated: snap.size >= QUERY_LIMIT };
144
143
  };
145
- function buildQuery() {
146
- return ctx.db.collection(PRODUCT_COLLECTION);
147
- }
144
+ /*
145
+ * 🛑 A `buildQuery()` helper used to sit here and was removed 2026-10-24. It
146
+ * was never CALLED — its only reference was `ReturnType<typeof buildQuery>` in
147
+ * the signature above, and since `Q` is declared one line up and the function
148
+ * returned `Q`, that resolved to `Q` anyway. So the whole function existed to
149
+ * name a type that already had a name, which is why it read as used to a human
150
+ * and as unused to eslint. Every caller builds its own query because each
151
+ * needs different `where` clauses.
152
+ */
148
153
  /** Seller metrics scope to the store; admin metrics span the platform. */
149
154
  const storeScoped = (q) => bucket.scope === "seller" ? q.where(PRODUCT_FIELDS.STORE_ID, "==", bucket.ownerId) : q;
150
155
  const compute = async (metric, windowHours) => {
@@ -219,6 +219,28 @@ export async function runCategoryTaxonomyBackfill(payload, ctx) {
219
219
  skipped.push(doc.id);
220
220
  continue;
221
221
  }
222
+ /*
223
+ * 🛑 PARTIAL resolution is reported, not acted on — and the distinction is
224
+ * the reason. `unresolved` was set here and never read until 2026-10-24, so
225
+ * a product carrying one dangling category id had that id silently dropped
226
+ * from its closure: exactly the shortening the comment above this loop warns
227
+ * about ("dropping it would shorten the closure and un-list the product from
228
+ * a category it is genuinely in").
229
+ *
230
+ * It is NOT turned into a skip, because the two causes are indistinguishable
231
+ * from inside this job: a dangling id may be a category that was legitimately
232
+ * DELETED, in which case dropping it is the correct repair, or a data error,
233
+ * in which case writing the short chain is the defect. Skipping would leave
234
+ * the first case permanently un-repaired; guessing on a job that mutates
235
+ * every product in a subtree is worse than reporting it.
236
+ */
237
+ if (unresolved) {
238
+ ctx.logger.warn("categoryTaxonomyBackfill: product has unresolvable category ids", {
239
+ productId: doc.id,
240
+ assigned: assignments.length,
241
+ resolved: resolved.length,
242
+ });
243
+ }
222
244
  /*
223
245
  * The default is the DEEPEST assignment, and the tie-break is deliberately
224
246
  * NOT consulted here: reading counts per product would be one extra read
@@ -6,7 +6,6 @@ import { CATEGORY_COUNTS_COLLECTION } from "../../../../features/categories/sche
6
6
  import { ORDER_FIELDS, PRODUCT_FIELDS } from "../../../../constants/field-names.js";
7
7
  import { isListingRowAvailable } from "../../../shared/listing-types/_registry.js";
8
8
  import { QUERY_LIMIT } from "../handlers/messages.js";
9
- const STORES_COLLECTION = "stores";
10
9
  const ORDERS_COLLECTION = "orders";
11
10
  /** Page size for the products scan. Bounded reads, unbounded total coverage. */
12
11
  const PAGE_SIZE = 500;
@@ -1,5 +1,5 @@
1
1
  import { adminNotificationsRepository } from "../../../../repositories/index.js";
2
- export const onCatalogueSubmittedForApprovalHandler = async (event, ctx) => {
2
+ export const onCatalogueSubmittedForApprovalHandler = async (event) => {
3
3
  const before = event.before?.listingStatus;
4
4
  const after = event.after?.listingStatus;
5
5
  if (before === "pending_admin_approval" || after !== "pending_admin_approval")
@@ -38,17 +38,25 @@ export interface PublicSiteSettings {
38
38
  otpCheckoutThreshold?: number;
39
39
  };
40
40
  /**
41
- * Which listing types the site offers — the client needs this to avoid
42
- * rendering a browse tab for a type nothing will return.
41
+ * Which listing types the site offers, and which browse sections are open —
42
+ * the client needs both to avoid rendering a nav entry or a browse tab that
43
+ * leads somewhere nothing will return.
43
44
  *
44
45
  * Deliberately NOT the whole `listings` block: `categoryTypes` has no client
45
46
  * reader. This was `featureFlags.listingTypes` until that group was deleted
46
47
  * on 2026-08-29; the projection stayed narrow for the same reason it always
47
48
  * was — a public payload carries what a client demonstrably reads, nothing
48
49
  * more (Root Cause #70).
50
+ *
51
+ * `browsePages` HAS a client reader and must be published: `LayoutShellClient`
52
+ * filters the navbar, the sidebar, the mobile sheet and the footer from it.
53
+ * Withholding it would leave every link to a closed section in the chrome,
54
+ * pointing at a route that now 404s — a dead-end CTA on every page of the
55
+ * site, which is a worse outcome than not having the flag at all.
49
56
  */
50
57
  listings: {
51
58
  listingTypes?: NonNullable<SiteSettingsDocument["listings"]>["listingTypes"];
59
+ browsePages?: NonNullable<SiteSettingsDocument["listings"]>["browsePages"];
52
60
  };
53
61
  /** D9 — off by default; `enabled: false` is emitted rather than omitted, same `always-present` reasoning as `disabledRoutes` below. */
54
62
  chat?: {
@@ -155,7 +155,6 @@ export function segmentSheet(raster, params) {
155
155
  let labelled;
156
156
  let beforeBridge;
157
157
  let merges = 0;
158
- let sameMaterialDropped = 0;
159
158
  /*
160
159
  * 1 on the art path, where the flood never runs and the question of "was
161
160
  * there anything to flood through" does not arise. Reporting 0 there would
@@ -179,7 +178,19 @@ export function segmentSheet(raster, params) {
179
178
  const lines = detectLines(filtered, width, height);
180
179
  const lineBarrier = thresholdAdaptive(lines.response, width, height, params.ridgeHysteresisLow, params.ridgeHysteresisHigh);
181
180
  const material = sameMaterialScore(raster, lineBarrier, filtered, params.paperTolerance * 2);
182
- sameMaterialDropped = Math.round(material.meanScore * 1000);
181
+ /*
182
+ * 🛑 `sameMaterialDropped = Math.round(material.meanScore * 1000)` used to sit
183
+ * here and was removed 2026-10-24: the local was assigned and never read, is
184
+ * not in the `diagnostics` object this function returns, and is not declared on
185
+ * that type — so nothing anywhere could observe it.
186
+ *
187
+ * The `sameMaterialScore` call above stays, and that distinction is the point:
188
+ * `material.score` feeds `mergeWeakBoundaries` a few lines down, so the pass is
189
+ * load-bearing even though that one derived number was not. I deleted the call
190
+ * first, on a grep for `sameMaterialDropped` rather than for `material` — `tsc`
191
+ * caught it. Re-add the metric with a declared diagnostics field if it is ever
192
+ * actually wanted.
193
+ */
183
194
  const open = new Uint8Array(width * height);
184
195
  for (let i = 0; i < open.length; i++)
185
196
  open[i] = lineBarrier[i] ? 0 : 1;
@@ -24,8 +24,62 @@ interface FeatureFlagSnapshot {
24
24
  listings?: {
25
25
  listingTypes?: Partial<Record<ListingType, boolean>>;
26
26
  categoryTypes?: Partial<Record<CategoryType, boolean>>;
27
+ browsePages?: Partial<Record<BrowsePageKey, boolean>>;
27
28
  };
28
29
  }
30
+ /**
31
+ * A public BROWSE SECTION that can be withdrawn as a whole.
32
+ *
33
+ * 🛑 This is a THIRD axis, not a rename of either of the two above, and the
34
+ * distinction is what stops it becoming a duplicate control:
35
+ *
36
+ * `listingTypes` — what the marketplace SELLS (is there such a thing as an
37
+ * auction here?)
38
+ * `categoryTypes` — what a taxonomy ROW may be (may a category row be a
39
+ * brand?)
40
+ * `browsePages` — whether a public ENTRY POINT is open (is there a /brands
41
+ * directory to walk?)
42
+ *
43
+ * They are genuinely independent. `brand` rows still tag products and still
44
+ * drive `BrandDetailPageView`'s query with `/brands` closed; closing the
45
+ * directory withdraws the front door, it does not untag the catalogue. That is
46
+ * why this cannot be folded into `categoryTypes` — and `categories`, `stores`
47
+ * and `franchise` have no `categoryTypes` member to fold into at all.
48
+ *
49
+ * `stores` covers BOTH `/stores` and `/sellers`: they are two doors onto the
50
+ * same directory (the footer lists them as "Stores" and "Verified Sellers"),
51
+ * and leaving one open while closing the other publishes exactly what the
52
+ * other was closed to hide.
53
+ */
54
+ export type BrowsePageKey = "categories" | "brands" | "franchise" | "stores";
55
+ export declare const ALL_BROWSE_PAGES: BrowsePageKey[];
56
+ /**
57
+ * `true` when the section is missing from the flag map OR explicitly enabled.
58
+ *
59
+ * Absent-means-enabled, matching its two siblings: a section has to be
60
+ * EXPLICITLY `false` to close. An install that predates this setting keeps
61
+ * every section open, so adding the field cannot take a site's navigation away
62
+ * from it.
63
+ */
64
+ export declare function isBrowsePageEnabled(page: BrowsePageKey, settings: FeatureFlagSnapshot | null | undefined): boolean;
65
+ export declare function enabledBrowsePages(settings: FeatureFlagSnapshot | null | undefined): BrowsePageKey[];
66
+ /** Narrow an untrusted string — a route param, a form value — to the union. */
67
+ export declare function isBrowsePageKey(value: unknown): value is BrowsePageKey;
68
+ /**
69
+ * Which browse section a link points into, or `null` for a link that is not in
70
+ * one. Used to drop nav, sidebar, mobile-sheet and footer entries for a closed
71
+ * section.
72
+ *
73
+ * 🛑 Matches on an EXACT path or a `/`-delimited descendant, never a bare
74
+ * `startsWith`. `/store` is the SELLER DASHBOARD and `/stores` is the public
75
+ * directory, so a prefix test would hide a signed-in seller's own dashboard
76
+ * link the moment an admin closed the public storefronts — a different
77
+ * surface, a different audience, one character apart.
78
+ *
79
+ * Query strings and hashes are tolerated because footer and nav hrefs are
80
+ * hand-authored and some carry them.
81
+ */
82
+ export declare function browsePageForPath(href: string | undefined | null): BrowsePageKey | null;
29
83
  /**
30
84
  * `true` when the type is missing from the flag map OR explicitly enabled.
31
85
  * Defaulting to enabled keeps legacy data working — a flag has to be