@mohasinac/appkit 4.64.0 → 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 +4 -1
  139. package/dist/index.js +28 -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
@@ -43,33 +43,3 @@ export function toCurrency(value) {
43
43
  }
44
44
  return formatCurrency(value);
45
45
  }
46
- /*
47
- * 🛑 `toRelativeDate` WAS HERE AND IS GONE. Import it from
48
- * `features/admin/hooks/useAdminListingData` — the defining module now, and
49
- * the one 44 files already use (Root Cause #18: import from where a symbol is
50
- * defined, never from a convenience copy).
51
- *
52
- * This copy had drifted from that one and the drift was live: for a FUTURE
53
- * date, `deltaMs` is negative, so `deltaMs < minute` matched and this version
54
- * answered **"just now"** while the admin version answered **"1m ago"** — two
55
- * different wrong answers to "when is this deadline", depending on which file
56
- * a view happened to import from. The admin one is fixed to say "in 48h"; a
57
- * second implementation is how that fix would have been half-applied.
58
- *
59
- * The three sibling helpers below are also duplicated there. They are left
60
- * alone deliberately: none of them is wrong, and collapsing them is an import
61
- * rewrite across both feature trees rather than a bug fix.
62
- */
63
- function parseDate(value) {
64
- if (value instanceof Date)
65
- return value;
66
- if (typeof value === "string") {
67
- const parsed = new Date(value);
68
- return Number.isNaN(parsed.getTime()) ? null : parsed;
69
- }
70
- if (typeof value === "number") {
71
- const parsed = new Date(value);
72
- return Number.isNaN(parsed.getTime()) ? null : parsed;
73
- }
74
- return null;
75
- }
@@ -17,19 +17,10 @@ import { normalizeError } from "../../../../../errors/normalize.js";
17
17
  import { NextResponse } from "next/server.js";
18
18
  import { getProviders } from "../../../../../contracts/index.js";
19
19
  const SAFE_STORE_AUCTION_FILTER_FIELDS = new Set(["price", "category"]);
20
- function validateSieveFilters(raw, allowedFields) {
21
- return raw
22
- .split(",")
23
- .map((c) => c.trim())
24
- .filter((c) => {
25
- const m = c.match(/^([^<>=!@]+)\s*(?:==|!=|<=|>=|<|>|@=\*?)/);
26
- return m ? allowedFields.has(m[1].trim()) : false;
27
- })
28
- .join(",");
29
- }
30
20
  import { parseListingParams } from "../../../../../utils/listing-params.js";
31
21
  import { SIEVE_OP, sieveFilter } from "../../../../../utils/sieve-builder.js";
32
22
  import { PRODUCT_FIELDS } from "../../../../../constants/field-names.js";
23
+ import { validateSieveFilters } from "../../../../../utils/sieve-builder.js";
33
24
  const DEFAULT_PAGE = 1;
34
25
  const DEFAULT_PAGE_SIZE = 24;
35
26
  const DEFAULT_SORT = "-createdAt";
@@ -19,6 +19,7 @@ import { getProviders } from "../../../../../contracts/index.js";
19
19
  import { parseListingParams } from "../../../../../utils/listing-params.js";
20
20
  import { SIEVE_OP, sieveFilter } from "../../../../../utils/sieve-builder.js";
21
21
  import { PRODUCT_FIELDS } from "../../../../../constants/field-names.js";
22
+ import { validateSieveFilters } from "../../../../../utils/sieve-builder.js";
22
23
  const DEFAULT_PAGE = 1;
23
24
  const DEFAULT_PAGE_SIZE = 24;
24
25
  const DEFAULT_SORT = "-createdAt";
@@ -28,16 +29,6 @@ const SAFE_STORE_PRODUCT_FILTER_FIELDS = new Set([
28
29
  "brand",
29
30
  "condition",
30
31
  ]);
31
- function validateSieveFilters(raw, allowedFields) {
32
- return raw
33
- .split(",")
34
- .map((c) => c.trim())
35
- .filter((c) => {
36
- const m = c.match(/^([^<>=!@]+)\s*(?:==|!=|<=|>=|<|>|@=\*?)/);
37
- return m ? allowedFields.has(m[1].trim()) : false;
38
- })
39
- .join(",");
40
- }
41
32
  // --- GET /api/stores/[storeSlug]/products -------------------------------------
42
33
  export async function GET(request, context) {
43
34
  try {
@@ -16,6 +16,14 @@ export function InteractiveStoreCard({ store, href, selectable, isSelected = fal
16
16
  const [logoBroken, setLogoBroken] = useState(false);
17
17
  const hasLogo = Boolean(store.storeLogoURL) && !logoBroken;
18
18
  const hasBanner = Boolean(store.storeBannerURL);
19
+ /*
20
+ * The rating badge used to live ONLY as an overlay on the banner, so making
21
+ * the banner conditional would have silently dropped the rating from every
22
+ * bannerless store — a hidden second effect of a layout change (Root Cause
23
+ * #57's family). It renders in the stats row instead when there is no banner
24
+ * to sit on, so the card never loses the number.
25
+ */
26
+ const hasRating = store.averageRating != null && store.averageRating > 0;
19
27
  const longPress = useLongPress(() => onSelect?.(store.id, !isSelected));
20
28
  const card = (_jsxs(Div, { className: [
21
29
  "group relative flex flex-col h-full overflow-hidden rounded-xl border bg-[var(--appkit-color-surface)] shadow-sm transition-shadow hover:shadow-lg",
@@ -26,7 +34,7 @@ export function InteractiveStoreCard({ store, href, selectable, isSelected = fal
26
34
  ].join(" "), onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => {
27
35
  e.preventDefault();
28
36
  onSelect(store.id, !isSelected);
29
- }, label: isSelected ? "Deselect store" : "Select store", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(Link, { href: href, className: "flex flex-col flex-1 min-h-0", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-video w-full ${__O.hidden} flex-shrink-0`, children: [hasBanner ? (_jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner", className: "h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Row, { className: "absolute inset-0", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-20 select-none", size: "5xl", "aria-hidden": "true", children: "\uD83C\uDFEA" }) })), _jsx(Scrim, { direction: "bottom-up", intensity: "subtle", className: "absolute inset-x-0 bottom-0 h-12" }), store.averageRating != null && store.averageRating > 0 && (_jsxs(Div, { className: CLS_RATING_BADGE, children: ["\u2605 ", store.averageRating.toFixed(1)] }))] }), _jsxs(Stack, { paddingY: "b-md", className: "flex-1", padding: "x-md", children: [_jsx(Row, { className: "-mt-5 mb-2", align: "end", justify: "between", children: _jsx(Div, { className: "flex-shrink-0", children: hasLogo ? (_jsx(Div, { className: "relative h-10 w-10 border-2 border-[var(--appkit-color-border-subtle)] bg-[var(--appkit-color-surface)]", rounded: "lg", shadow: "md", overflow: "hidden", children: _jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "thumbnail", onError: () => setLogoBroken(true) }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", className: "h-10 w-10 border-2 border-white bg-primary/10 dark:bg-primary/20 text-primary", align: "center", justify: "center", rounded: "lg", shadow: "md", children: initial })) }) }), _jsx(Heading, { level: 3, className: `group-hover:text-primary transition-colors`, truncate: true, size: "sm", weight: "bold", color: "primary", children: store.storeName }), _jsx(SellerTierBadge, { isVerified: store.isVerified, sellerTier: store.sellerTier, size: "xs" }), store.storeDescription ? (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mt-1 line-clamp-2 text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-1" })) : (_jsx(Div, { className: "flex-1" })), _jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", className: "mt-2.5", wrap: true, children: [store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), " ", store.totalProducts, " ", labels.products ?? "products"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDECD\uFE0F" }), " ", store.itemsSold, " ", labels.sold ?? "sold"] }))] }), _jsxs(Row, { border: "top-subtle", paddingY: "y-xs-tall", className: "mt-3", align: "center", justify: "between", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "text-primary group-hover:underline transition-colors", children: [labels.visitStore ?? "Visit store", " \u2192"] }), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { size: "xs", color: "muted", children: [store.totalReviews, " ", labels.reviews ?? "reviews"] }))] })] })] })] }));
37
+ }, label: isSelected ? "Deselect store" : "Select store", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(Link, { href: href, className: "flex flex-col flex-1 min-h-0", children: [hasBanner && (_jsxs(Div, { surface: "muted", className: `relative aspect-video w-full ${__O.hidden} flex-shrink-0`, children: [_jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner", className: "h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" }), _jsx(Scrim, { direction: "bottom-up", intensity: "subtle", className: "absolute inset-x-0 bottom-0 h-12" }), hasRating && (_jsxs(Div, { className: CLS_RATING_BADGE, children: ["\u2605 ", store.averageRating.toFixed(1)] }))] })), _jsxs(Stack, { paddingY: "b-md", className: "flex-1", padding: "x-md", children: [_jsx(Row, { className: hasBanner ? "-mt-5 mb-2" : "mt-3 mb-2", align: "end", justify: "between", children: _jsx(Div, { className: "flex-shrink-0", children: hasLogo ? (_jsx(Div, { className: "relative h-10 w-10 border-2 border-[var(--appkit-color-border-subtle)] bg-[var(--appkit-color-surface)]", rounded: "lg", shadow: "md", overflow: "hidden", children: _jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "thumbnail", onError: () => setLogoBroken(true) }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", className: "h-10 w-10 border-2 border-white bg-primary/10 dark:bg-primary/20 text-primary", align: "center", justify: "center", rounded: "lg", shadow: "md", children: initial })) }) }), _jsx(Heading, { level: 3, className: `group-hover:text-primary transition-colors`, truncate: true, size: "sm", weight: "bold", color: "primary", children: store.storeName }), _jsx(SellerTierBadge, { isVerified: store.isVerified, sellerTier: store.sellerTier, size: "xs" }), store.storeDescription ? (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mt-1 line-clamp-2 text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-1" })) : (_jsx(Div, { className: "flex-1" })), _jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", className: "mt-2.5", wrap: true, children: [!hasBanner && hasRating && (_jsxs(Span, { layout: "flex", gap: "2xs", className: "text-warning", children: [_jsx(Span, { "aria-hidden": "true", children: "\u2605" }), " ", store.averageRating.toFixed(1)] })), store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), " ", store.totalProducts, " ", labels.products ?? "products"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDECD\uFE0F" }), " ", store.itemsSold, " ", labels.sold ?? "sold"] }))] }), _jsxs(Row, { border: "top-subtle", paddingY: "y-xs-tall", className: "mt-3", align: "center", justify: "between", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "text-primary group-hover:underline transition-colors", children: [labels.visitStore ?? "Visit store", " \u2192"] }), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { size: "xs", color: "muted", children: [store.totalReviews, " ", labels.reviews ?? "reviews"] }))] })] })] })] }));
30
38
  if (!renderOwnerActions)
31
39
  return card;
32
40
  /*
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Package, ShoppingBag, Wallet, Settings, Shield, Headphones, CheckCircle2, Circle, BookOpen, } from "lucide-react";
2
+ import { Package, ShoppingBag, Wallet, Settings, Shield, Headphones, CheckCircle2, Circle, BookOpen } from "lucide-react";
3
3
  import { Div, Grid, Heading, Progress, Row, Section, Stack, Text, TextLink } from "../../../ui/index.js";
4
4
  import { SellerStandingPanel } from "./SellerStandingPanel.js";
5
5
  import { ROUTES } from "../../../next/routing/route-map.js";
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
- type SearchParams = Record<string, string | string[]>;
2
+ import type { SearchParamsRecord } from "../../../utils/search-params";
3
3
  export interface StoresIndexPageViewProps {
4
- searchParams?: SearchParams;
4
+ searchParams?: SearchParamsRecord;
5
5
  /**
6
6
  * Restrict to verified stores and retitle the page. This is what /sellers is.
7
7
  *
@@ -19,4 +19,3 @@ export interface StoresIndexPageViewProps {
19
19
  heading?: string;
20
20
  }
21
21
  export declare function StoresIndexPageView({ searchParams, verifiedOnly, heading, }: StoresIndexPageViewProps): Promise<React.JSX.Element>;
22
- export {};
@@ -6,14 +6,11 @@ import { StoresIndexListing } from "./StoresIndexListing.js";
6
6
  import { safeRead } from "../../../errors/safe-read.js";
7
7
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
8
8
  import { STORE_FIELDS } from "../../../constants/field-names.js";
9
- function sp(params, key) {
10
- const v = params[key];
11
- return Array.isArray(v) ? v[0] ?? "" : v ?? "";
12
- }
9
+ import { firstParam } from "../../../utils/search-params.js";
13
10
  export async function StoresIndexPageView({ searchParams = {}, verifiedOnly = false, heading = "Stores", }) {
14
- const sort = sp(searchParams, "sort") || "-createdAt";
15
- const page = Number(sp(searchParams, "page")) || 1;
16
- const pageSize = Number(sp(searchParams, "pageSize")) || 24;
11
+ const sort = firstParam(searchParams, "sort") || "-createdAt";
12
+ const page = Number(firstParam(searchParams, "page")) || 1;
13
+ const pageSize = Number(firstParam(searchParams, "pageSize")) || 24;
17
14
  /*
18
15
  * 🛑 `q` MUST be read here, and passed as the third argument exactly as
19
16
  * /api/stores does — `listStores(model, true, { search })`.
@@ -29,14 +26,14 @@ export async function StoresIndexPageView({ searchParams = {}, verifiedOnly = fa
29
26
  * frozen for that query key. Root Cause #30 — the SSR builder has to compute
30
27
  * the same filters the client would, because it never gets a second chance.
31
28
  */
32
- const q = sp(searchParams, "q").trim();
29
+ const q = firstParam(searchParams, "q").trim();
33
30
  /*
34
31
  * The rating facet must be applied HERE too, not only by the client. This
35
32
  * result is handed to <StoresIndexListing initialData=…>, and public listing
36
33
  * hooks set staleTime: Infinity when given SSR data — so an unfiltered first
37
34
  * paint is frozen for that query key and never self-corrects (Root Cause #30).
38
35
  */
39
- const ratingRaw = sp(searchParams, "rating");
36
+ const ratingRaw = firstParam(searchParams, "rating");
40
37
  const minRating = ratingRaw
41
38
  ? Math.max(...ratingRaw.split("|").map(Number).filter((n) => Number.isFinite(n)))
42
39
  : undefined;
@@ -415,6 +415,31 @@ export interface StoreDocument extends BaseDocument {
415
415
  * than trusting that nobody adds it.
416
416
  */
417
417
  returnsDirectToSeller?: boolean;
418
+ /**
419
+ * Does this store publish a WhatsApp catalogue feed?
420
+ *
421
+ * 🛑 **ABSENT MEANS ENABLED**, and the polarity is load-bearing rather than a
422
+ * convenience. Every store that predates this field is already feeding a live
423
+ * Commerce Manager data source; a `=== true` reader would turn all of them off
424
+ * at once, and Meta EMPTIES a connected catalogue the first time a scheduled
425
+ * fetch returns a file with no rows. So the only state that disables a feed is
426
+ * somebody deliberately writing `false` — the same `!== false` kill-switch
427
+ * polarity as `siteSettings.search.indexEnabled`, and for the same reason.
428
+ *
429
+ * 🛑 Disabling is DESTRUCTIVE on Meta's side and that is the point. The feed
430
+ * route answers a disabled store with **200 and a header row only**, which is
431
+ * what makes the seller's next scheduled fetch clear their WhatsApp catalogue.
432
+ * Its 404 branch (unpublished / suspended / missing store) exists to avoid
433
+ * exactly that outcome for a TEMPORARY state. The distinction is intent: a
434
+ * suspension is not a decision to unpublish a catalogue, and a seller flipping
435
+ * this switch is.
436
+ *
437
+ * 🛑 PRIVATE in the public store projection. It describes a seller's own
438
+ * distribution plumbing, not the goods — and publishing it would advertise
439
+ * which stores are reachable on WhatsApp, which `whatsappNotifySubscribed`
440
+ * is private for too.
441
+ */
442
+ whatsappFeedEnabled?: boolean;
418
443
  /**
419
444
  * Who changed what, when, and why. See § "Status History" in CLAUDE.md.
420
445
  *
@@ -479,6 +504,8 @@ export declare const STORE_FIELDS: {
479
504
  readonly TIER_LOCKED_BY_ADMIN: "tierLockedByAdmin";
480
505
  readonly SUSPENSION_REASON: "suspensionReason";
481
506
  readonly CAPABILITIES: "capabilities";
507
+ /** Per-store WhatsApp catalogue feed kill switch. Absent means ENABLED. */
508
+ readonly WHATSAPP_FEED_ENABLED: "whatsappFeedEnabled";
482
509
  readonly STATUS_VALUES: {
483
510
  readonly PENDING: "pending";
484
511
  readonly ACTIVE: "active";
@@ -92,6 +92,8 @@ export const STORE_FIELDS = {
92
92
  TIER_LOCKED_BY_ADMIN: "tierLockedByAdmin",
93
93
  SUSPENSION_REASON: "suspensionReason",
94
94
  CAPABILITIES: "capabilities",
95
+ /** Per-store WhatsApp catalogue feed kill switch. Absent means ENABLED. */
96
+ WHATSAPP_FEED_ENABLED: "whatsappFeedEnabled",
95
97
  STATUS_VALUES: {
96
98
  PENDING: "pending",
97
99
  ACTIVE: "active",
@@ -1,4 +1,5 @@
1
1
  import { normalizeError } from "../../../errors/normalize.js";
2
+ import { getDefaultStorage } from "../../../utils/browser-storage.js";
2
3
  const DEFAULT_GUEST_WISHLIST_KEY = process.env.NEXT_PUBLIC_APP_ID
3
4
  ? `${process.env.NEXT_PUBLIC_APP_ID}_guest_wishlist`
4
5
  : "guest_wishlist";
@@ -12,11 +13,6 @@ function dispatchGuestWishlistChange() {
12
13
  return;
13
14
  window.dispatchEvent(new Event(GUEST_WISHLIST_CHANGE_EVENT));
14
15
  }
15
- function getDefaultStorage() {
16
- if (typeof window === "undefined")
17
- return null;
18
- return window.localStorage;
19
- }
20
16
  function readItems(storage, key) {
21
17
  if (!storage)
22
18
  return [];
package/dist/index.d.ts CHANGED
@@ -2188,7 +2188,8 @@ export { getProductSortOptions } from "./features/products/index";
2188
2188
  export { getProductTableColumns } from "./features/products/index";
2189
2189
  export { normalizeListingType, isAuctionListing, isPreOrderListing, isStandardListing, isPrizeDrawListing, isClassifiedListing, isDigitalCodeListing, isLiveListing, isArtListing, isStickersListing, } from "./features/products/index";
2190
2190
  export { isFinalSale, acceptsChangeOfMindReturns, } from "./features/products/constants/final-sale";
2191
- export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, isListingType, } from "./_internal/shared/listing-types/feature-flags";
2191
+ export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, isListingType, isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey, } from "./_internal/shared/listing-types/feature-flags";
2192
+ export type { BrowsePageKey } from "./_internal/shared/listing-types/feature-flags";
2192
2193
  export { ADVANCED_LISTING_TYPE_CAPABILITY, ADVANCED_LISTING_TYPES, BASIC_LISTING_TYPES, LISTING_TYPE_TIER, assertListingTypeAllowed, hasAdvancedListingTypes, isAdvancedListingType, isListingTypeAllowedFor, listingTypesAllowedFor, } from "./_internal/shared/listing-types/tiers";
2193
2194
  export type { ListingTypeAccessContext, ListingTypeTier, } from "./_internal/shared/listing-types/tiers";
2194
2195
  export { actionTracker, setActionTrackerSink, resetActionTrackerSink, type ActionEvent, type ActionTrackerSink, } from "./_internal/shared/listing-types/action-tracker";
@@ -2852,6 +2853,8 @@ export { TAX_CODES_COLLECTION, TAX_CODE_PREFIX, TAX_CODE_FIELDS, TAX_CODE_SIEVE_
2852
2853
  export type { TaxCodeDocument, TaxCodeCreateInput, TaxCodeUpdateInput, GstRate, HsnChapter, } from "./features/tax-codes/schemas/firestore";
2853
2854
  export { taxCodeFormSchema, GST_RATE_OPTIONS, HSN_CHAPTER_OPTIONS } from "./features/tax-codes/schemas/tax-code-form";
2854
2855
  export type { TaxCodeFormValues } from "./features/tax-codes/schemas/tax-code-form";
2856
+ export { EVENT_LIKES_COLLECTION } from "./features/events/schemas/likes";
2857
+ export { DISPUTE_REASON_COLLECTION } from "./features/dispute-reasons/schemas/firestore";
2855
2858
  export { taxCodeCreateSchema, taxCodeUpdateSchema, } from "./features/tax-codes/schemas/tax-codes.validators";
2856
2859
  export type { TaxCodeCreatePayload, TaxCodeUpdatePayload, } from "./features/tax-codes/schemas/tax-codes.validators";
2857
2860
  export type { ProductFeatureDocument, ProductFeatureCreateInput, ProductFeatureUpdateInput, ProductFeatureScope, ProductFeatureCategory, ProductFeatureProductType, ProductFeatureIcon, } from "./features/products/schemas/product-features";
package/dist/index.js CHANGED
@@ -4075,7 +4075,13 @@ export { normalizeListingType, isAuctionListing, isPreOrderListing, isStandardLi
4075
4075
  // `finalSale` field. See the module docstring for why `!p.finalSale` is wrong.
4076
4076
  export { isFinalSale, acceptsChangeOfMindReturns, } from "./features/products/constants/final-sale.js";
4077
4077
  // SB-UNI-X4 2026-05-13 â€" per-type feature-flag helpers.
4078
- export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, isListingType, } from "./_internal/shared/listing-types/feature-flags.js";
4078
+ export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, isListingType,
4079
+ // Browse-section gates — /categories, /brands, /franchise, /stores.
4080
+ // Exported HERE rather than from server.ts for the reason given in that
4081
+ // file's ALL_LISTING_TYPES note: `server-entry.ts` re-exports index, so a
4082
+ // Server Component already reaches these from the bare specifier and a
4083
+ // second path would leak an `_internal/` symbol through a third barrel.
4084
+ isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey, } from "./_internal/shared/listing-types/feature-flags.js";
4079
4085
  // Basic/advanced listing-type split — which types a store may create.
4080
4086
  // Pure (no firebase-admin in its import chain), so it is safe in index.ts.
4081
4087
  export { ADVANCED_LISTING_TYPE_CAPABILITY, ADVANCED_LISTING_TYPES, BASIC_LISTING_TYPES, LISTING_TYPE_TIER, assertListingTypeAllowed, hasAdvancedListingTypes, isAdvancedListingType, isListingTypeAllowedFor, listingTypesAllowedFor, } from "./_internal/shared/listing-types/tiers.js";
@@ -5140,6 +5146,27 @@ export { PRODUCT_FEATURES_COLLECTION, PRODUCT_FEATURE_PREFIX, MAX_STORE_CUSTOM_F
5140
5146
  // Tax codes feature schemas (B3) — HSN + GST rate as an entity.
5141
5147
  export { TAX_CODES_COLLECTION, TAX_CODE_PREFIX, TAX_CODE_FIELDS, TAX_CODE_SIEVE_FIELDS, TAX_CODE_UPDATABLE_FIELDS, GST_RATE_VALUES, createTaxCodeId, isValidHsnCode, } from "./features/tax-codes/schemas/firestore.js";
5142
5148
  export { taxCodeFormSchema, GST_RATE_OPTIONS, HSN_CHAPTER_OPTIONS } from "./features/tax-codes/schemas/tax-code-form.js";
5149
+ /*
5150
+ * 🛑 THE SEED LOADER DESTRUCTURES THESE TWO OFF THE BARREL, so an unexported
5151
+ * collection name is not a tidiness issue — it is a collection that can
5152
+ * never load.
5153
+ *
5154
+ * `seed-cli.mjs` reads `COLLECTION_MAP` from one big destructure of
5155
+ * `@mohasinac/appkit`. A name missing there is `undefined`, which Firestore
5156
+ * rejects as `Value for argument "collectionPath" is not a valid resource
5157
+ * path` — so `appkit-seed status` and `load` both error on that row alone
5158
+ * while every other collection succeeds. Measured 2026-10-24: eventLikes
5159
+ * (23 rows) and disputeReasons (11 rows) were both stuck at 0 in the
5160
+ * database for exactly this reason, and the error names the path, never the
5161
+ * missing export.
5162
+ *
5163
+ * Exported straight from the defining schema module rather than through a
5164
+ * feature index (Root Cause #18). Both files carry ONLY `import type`, which
5165
+ * the compiler erases, so neither drags a runtime chain into the client
5166
+ * bundle — the check that matters before adding anything here.
5167
+ */
5168
+ export { EVENT_LIKES_COLLECTION } from "./features/events/schemas/likes.js";
5169
+ export { DISPUTE_REASON_COLLECTION } from "./features/dispute-reasons/schemas/firestore.js";
5143
5170
  // Wire contracts for /api/admin/tax-codes. BOTH are .strict(): z.object()
5144
5171
  // strips unknown keys (a PATCH returning 200 having written nothing),
5145
5172
  // .passthrough() writes whatever arrives.
@@ -30,7 +30,13 @@ export function getAdminAppLite() {
30
30
  process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET?.trim();
31
31
  let app;
32
32
  if (nodeFs().existsSync(keyPath)) {
33
- const sa = JSON.parse(nodeFs().readFileSync(keyPath, "utf8"));
33
+ /*
34
+ * 🛑 A `const sa = JSON.parse(readFileSync(keyPath))` sat here and was removed
35
+ * 2026-10-24: the parsed object was never used, because `cert(keyPath)` below
36
+ * takes the PATH and reads and validates the file itself. The only thing the
37
+ * extra parse bought was a second read of a service-account key on every
38
+ * cold start.
39
+ */
34
40
  const dbUrl = resolveRtdbUrl();
35
41
  app = initializeApp({ credential: cert(keyPath), databaseURL: dbUrl, ...(storageBucket && { storageBucket }) });
36
42
  }
@@ -63,7 +69,13 @@ export function getAdminAppLite() {
63
69
  const firebaseConfig = process.env.FIREBASE_CONFIG
64
70
  ? JSON.parse(process.env.FIREBASE_CONFIG)
65
71
  : {};
66
- const projectId = firebaseConfig.projectId ?? process.env.GCLOUD_PROJECT ?? "";
72
+ /*
73
+ * 🛑 A `projectId` was computed here from FIREBASE_CONFIG/GCLOUD_PROJECT and
74
+ * never passed, which was CORRECT but dead: this is the ADC branch, and
75
+ * `initializeApp()` with no credential is exactly what makes the SDK resolve
76
+ * both credential and project from the metadata server. Removed 2026-10-24
77
+ * rather than wired, because passing it would override that resolution.
78
+ */
67
79
  const dbUrl = firebaseConfig.databaseURL ??
68
80
  resolveRtdbUrl();
69
81
  app = initializeApp({
@@ -83,7 +83,10 @@ export function getAdminApp() {
83
83
  let app;
84
84
  try {
85
85
  if (nodeFs().existsSync(keyPath)) {
86
- const sa = JSON.parse(nodeFs().readFileSync(keyPath, "utf8"));
86
+ /*
87
+ * 🛑 Removed 2026-10-24 — see the matching note in admin-app-lite.ts. The
88
+ * parsed service account was never used; `cert(keyPath)` reads the file.
89
+ */
87
90
  // Was NEXT_PUBLIC-only here while the other two branches honoured
88
91
  // FIREBASE_ADMIN_DATABASE_URL, so setting it had no effect on this path.
89
92
  const dbUrl = resolveRtdbUrl();
@@ -118,7 +121,10 @@ export function getAdminApp() {
118
121
  const firebaseConfig = process.env.FIREBASE_CONFIG
119
122
  ? JSON.parse(process.env.FIREBASE_CONFIG)
120
123
  : {};
121
- const projectId = firebaseConfig.projectId ?? process.env.GCLOUD_PROJECT ?? "";
124
+ /*
125
+ * 🛑 Removed 2026-10-24 — see admin-app-lite.ts. Computed, never passed, and
126
+ * passing it would defeat ADC's own project resolution.
127
+ */
122
128
  // FIREBASE_CONFIG is injected by the Functions runtime and is
123
129
  // authoritative there; env vars are the fallback for other runtimes.
124
130
  const dbUrl = firebaseConfig.databaseURL || resolveRtdbUrl();
@@ -0,0 +1,26 @@
1
+ import { type BrowsePageKey } from "../../_internal/shared/listing-types/feature-flags";
2
+ /**
3
+ * Which public browse sections are open, as configured in
4
+ * `siteSettings.listings.browsePages`.
5
+ *
6
+ * The sibling of `useListingTypeFlags`, and built the same way on purpose:
7
+ * keyed on the union via `ALL_BROWSE_PAGES` so a new section cannot be
8
+ * silently missed, and DEFAULTING TO OPEN while settings load so the first
9
+ * paint does not briefly hide navigation that is actually enabled.
10
+ *
11
+ * 🛑 The permissive default matters more here than it does for listing types.
12
+ * This hook decides whether a nav entry RENDERS, so a `false` default would
13
+ * blank the navbar on every cold load until the settings request resolved —
14
+ * visible flicker on every page of the site rather than one empty browse tab.
15
+ */
16
+ export type BrowsePageFlagsShape = Partial<Record<BrowsePageKey, boolean>>;
17
+ export type BrowsePageFlags = Record<BrowsePageKey, boolean> & {
18
+ /** True when the section is open (or the setting has not loaded yet). */
19
+ isEnabled: (page: BrowsePageKey) => boolean;
20
+ /**
21
+ * True when a link may be shown — i.e. it points at no browse section, or at
22
+ * one that is open. Pass an href straight from a nav or footer constant.
23
+ */
24
+ allowsHref: (href: string | undefined | null) => boolean;
25
+ };
26
+ export declare function useBrowsePageFlags(): BrowsePageFlags;
@@ -0,0 +1,18 @@
1
+ "use client";
2
+ import { useSiteSettings } from "../../core/hooks/useSiteSettings.js";
3
+ import { ALL_BROWSE_PAGES, browsePageForPath, } from "../../_internal/shared/listing-types/feature-flags.js";
4
+ export function useBrowsePageFlags() {
5
+ const { data } = useSiteSettings();
6
+ const bp = data?.listings?.browsePages;
7
+ // Closed only when EXPLICITLY false — a missing flag, or settings that have
8
+ // not loaded, means open.
9
+ const flags = Object.fromEntries(ALL_BROWSE_PAGES.map((page) => [page, bp?.[page] !== false]));
10
+ return {
11
+ ...flags,
12
+ isEnabled: (page) => flags[page],
13
+ allowsHref: (href) => {
14
+ const page = browsePageForPath(href);
15
+ return page === null || flags[page];
16
+ },
17
+ };
18
+ }
@@ -2573,7 +2573,7 @@ export declare const SCHEMAS: {
2573
2573
  isActive: boolean;
2574
2574
  isFeatured: boolean;
2575
2575
  productIds: string[];
2576
- groupTheme: "set" | "related" | "character" | "lineage" | "generic";
2576
+ groupTheme: "set" | "character" | "related" | "lineage" | "generic";
2577
2577
  minActiveMembers: number;
2578
2578
  visibilityStatus: "hidden" | "visible";
2579
2579
  activeMemberCount: number;
@@ -2596,7 +2596,7 @@ export declare const SCHEMAS: {
2596
2596
  isActive: boolean;
2597
2597
  isFeatured: boolean;
2598
2598
  productIds: string[];
2599
- groupTheme: "set" | "related" | "character" | "lineage" | "generic";
2599
+ groupTheme: "set" | "character" | "related" | "lineage" | "generic";
2600
2600
  minActiveMembers: number;
2601
2601
  visibilityStatus: "hidden" | "visible";
2602
2602
  activeMemberCount: number;
@@ -5332,7 +5332,7 @@ export declare const SCHEMAS: {
5332
5332
  isActive: boolean;
5333
5333
  isFeatured: boolean;
5334
5334
  productIds: string[];
5335
- groupTheme: "set" | "related" | "character" | "lineage" | "generic";
5335
+ groupTheme: "set" | "character" | "related" | "lineage" | "generic";
5336
5336
  minActiveMembers: number;
5337
5337
  description?: string | undefined;
5338
5338
  coverImage?: string | undefined;
@@ -5341,7 +5341,7 @@ export declare const SCHEMAS: {
5341
5341
  isActive: boolean;
5342
5342
  isFeatured: boolean;
5343
5343
  productIds: string[];
5344
- groupTheme: "set" | "related" | "character" | "lineage" | "generic";
5344
+ groupTheme: "set" | "character" | "related" | "lineage" | "generic";
5345
5345
  minActiveMembers: number;
5346
5346
  description?: string | undefined;
5347
5347
  coverImage?: string | undefined;
@@ -1,9 +1,39 @@
1
1
  /**
2
- * Content Security Policy helpers.
3
- * Generates a per-request nonce and builds the CSP header string.
2
+ * Content Security Policy helpers — **NOT WIRED. Do not wire as-is.**
3
+ *
4
+ * 🛑 Measured 2026-10-24: these two functions are exported from three barrels
5
+ * (`security/index.ts`, `security/browser.ts`, and appkit's main `index.ts`) and imported by
6
+ * **nothing** — not elsewhere in appkit, not in the consumer. That is Root Cause #87's shape:
7
+ * a module whose absence is silent because nothing registers it. For the whole time it
8
+ * existed, letitrip.in shipped no CSP at all.
9
+ *
10
+ * 🛑 **And wiring `buildCSP` unchanged would break the live site.** The policy below predates
11
+ * four integrations the app now depends on, and every one of them fails *silently, in someone
12
+ * else's browser*:
13
+ *
14
+ * - **Razorpay** — no `checkout.razorpay.com` / `api.razorpay.com` in `script-src`,
15
+ * `frame-src` or `connect-src`. Checkout would not load.
16
+ * - **YouTube embeds** — no `www.youtube-nocookie.com` in `frame-src`. Every product and
17
+ * blog video embed would be blocked.
18
+ * - **Firebase RTDB** — no `wss://*.firebasedatabase.app` in `connect-src` (only the older
19
+ * `firebaseio.com`). The auth/payment/bulk signal channels would stop.
20
+ * - **`frame-ancestors`** and **`object-src`** are absent entirely.
21
+ *
22
+ * **What ships instead, today:** a `Content-Security-Policy-Report-Only` header built inline
23
+ * in the consumer's `next.config.js`, covering all four of the above. Report-Only on purpose —
24
+ * an enforced policy guessed without report data is how you discover the gaps above from a
25
+ * user. `audit-security-headers` requires a CSP in either spelling and accepts Report-Only as
26
+ * a valid end state.
27
+ *
28
+ * **Why this file is kept rather than deleted:** the *nonce* approach here is the correct
29
+ * destination — `'nonce-…'` is strictly better than the `'unsafe-inline'` the inline policy
30
+ * currently needs. Getting there means generating a nonce in `src/proxy.ts`, threading it
31
+ * through `requestHeaders`, and attaching it to every inline script Next emits. That is its
32
+ * own change, and the policy string needs the four additions above first.
4
33
  *
5
34
  * @example
6
35
  * ```ts
36
+ * // The intended shape, once the nonce is threaded through proxy.ts:
7
37
  * const nonce = generateNonce();
8
38
  * const csp = buildCSP(nonce);
9
39
  * response.headers.set("Content-Security-Policy", csp);
@@ -1,9 +1,39 @@
1
1
  /**
2
- * Content Security Policy helpers.
3
- * Generates a per-request nonce and builds the CSP header string.
2
+ * Content Security Policy helpers — **NOT WIRED. Do not wire as-is.**
3
+ *
4
+ * 🛑 Measured 2026-10-24: these two functions are exported from three barrels
5
+ * (`security/index.ts`, `security/browser.ts`, and appkit's main `index.ts`) and imported by
6
+ * **nothing** — not elsewhere in appkit, not in the consumer. That is Root Cause #87's shape:
7
+ * a module whose absence is silent because nothing registers it. For the whole time it
8
+ * existed, letitrip.in shipped no CSP at all.
9
+ *
10
+ * 🛑 **And wiring `buildCSP` unchanged would break the live site.** The policy below predates
11
+ * four integrations the app now depends on, and every one of them fails *silently, in someone
12
+ * else's browser*:
13
+ *
14
+ * - **Razorpay** — no `checkout.razorpay.com` / `api.razorpay.com` in `script-src`,
15
+ * `frame-src` or `connect-src`. Checkout would not load.
16
+ * - **YouTube embeds** — no `www.youtube-nocookie.com` in `frame-src`. Every product and
17
+ * blog video embed would be blocked.
18
+ * - **Firebase RTDB** — no `wss://*.firebasedatabase.app` in `connect-src` (only the older
19
+ * `firebaseio.com`). The auth/payment/bulk signal channels would stop.
20
+ * - **`frame-ancestors`** and **`object-src`** are absent entirely.
21
+ *
22
+ * **What ships instead, today:** a `Content-Security-Policy-Report-Only` header built inline
23
+ * in the consumer's `next.config.js`, covering all four of the above. Report-Only on purpose —
24
+ * an enforced policy guessed without report data is how you discover the gaps above from a
25
+ * user. `audit-security-headers` requires a CSP in either spelling and accepts Report-Only as
26
+ * a valid end state.
27
+ *
28
+ * **Why this file is kept rather than deleted:** the *nonce* approach here is the correct
29
+ * destination — `'nonce-…'` is strictly better than the `'unsafe-inline'` the inline policy
30
+ * currently needs. Getting there means generating a nonce in `src/proxy.ts`, threading it
31
+ * through `requestHeaders`, and attaching it to every inline script Next emits. That is its
32
+ * own change, and the policy string needs the four additions above first.
4
33
  *
5
34
  * @example
6
35
  * ```ts
36
+ * // The intended shape, once the nonce is threaded through proxy.ts:
7
37
  * const nonce = generateNonce();
8
38
  * const csp = buildCSP(nonce);
9
39
  * response.headers.set("Content-Security-Policy", csp);