@mohasinac/appkit 4.27.0 → 4.28.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 (271) hide show
  1. package/dist/_internal/client/features/lottery/LotteryAdminEditView.d.ts +16 -16
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +29 -6
  3. package/dist/_internal/client/features/lottery/LotteryListView.js +13 -1
  4. package/dist/_internal/server/features/checkout/actions.js +6 -4
  5. package/dist/_internal/server/features/classified/metadata.js +3 -1
  6. package/dist/_internal/server/features/digital-code/metadata.js +3 -1
  7. package/dist/_internal/server/features/live/metadata.js +3 -1
  8. package/dist/_internal/server/features/refunds/actions.js +3 -2
  9. package/dist/_internal/server/features/reviews/actions.js +3 -2
  10. package/dist/_internal/server/features/seo/sitemap.js +189 -40
  11. package/dist/_internal/server/jobs/core/listingProcessor.d.ts +0 -1
  12. package/dist/_internal/server/jobs/core/listingProcessor.js +19 -1
  13. package/dist/_internal/shared/actions/action-registry.js +14 -0
  14. package/dist/_internal/shared/checkout/order-math.d.ts +25 -3
  15. package/dist/_internal/shared/features/checkout/config.d.ts +3 -3
  16. package/dist/_internal/shared/features/checkout/config.js +6 -3
  17. package/dist/client.d.ts +32 -2
  18. package/dist/client.js +37 -2
  19. package/dist/constants/api-endpoints.d.ts +21 -3
  20. package/dist/constants/api-endpoints.js +14 -1
  21. package/dist/constants/field-names.d.ts +2 -2
  22. package/dist/constants/field-names.js +2 -2
  23. package/dist/core/newsletter.repository.js +14 -1
  24. package/dist/features/about/components/FAQPageView.js +1 -1
  25. package/dist/features/about/components/FeesView.js +1 -1
  26. package/dist/features/account/components/AddressForm.d.ts +2 -0
  27. package/dist/features/account/components/AddressForm.js +5 -1
  28. package/dist/features/account/components/UserBidsView.js +1 -1
  29. package/dist/features/account/components/UserSidebar.js +1 -1
  30. package/dist/features/account/components/UserSupportView.js +24 -2
  31. package/dist/features/account/hooks/useAddresses.d.ts +29 -1
  32. package/dist/features/account/hooks/useAddresses.js +4 -1
  33. package/dist/features/account/hooks/useProfile.d.ts +1 -1
  34. package/dist/features/account/hooks/useProfile.js +1 -1
  35. package/dist/features/addresses/repository/addresses.repository.d.ts +13 -0
  36. package/dist/features/addresses/repository/addresses.repository.js +19 -0
  37. package/dist/features/admin/actions/chat-actions.js +5 -1
  38. package/dist/features/admin/components/AdminAdsView.js +1 -1
  39. package/dist/features/admin/components/AdminBidsView.js +6 -1
  40. package/dist/features/admin/components/AdminCarouselGroupEditorView.d.ts +9 -1
  41. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +42 -10
  42. package/dist/features/admin/components/AdminCarouselView.js +1 -1
  43. package/dist/features/admin/components/AdminFeatureEditorView.js +1 -1
  44. package/dist/features/admin/components/AdminGroupedListingsView.js +14 -2
  45. package/dist/features/admin/components/AdminSidebar.js +1 -1
  46. package/dist/features/admin/components/AdminViewCards.js +2 -2
  47. package/dist/features/admin/components/DataTable.js +2 -2
  48. package/dist/features/admin/schemas/firestore.js +9 -3
  49. package/dist/features/auctions/actions/bid-actions.js +45 -4
  50. package/dist/features/auctions/components/AuctionBottomActions.d.ts +21 -6
  51. package/dist/features/auctions/components/AuctionBottomActions.js +42 -11
  52. package/dist/features/auctions/components/AuctionDetailPageView.d.ts +8 -3
  53. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -2
  54. package/dist/features/auctions/components/MarketplaceAuctionCard.js +4 -5
  55. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +27 -2
  56. package/dist/features/auctions/components/PlaceBidFormClient.js +42 -20
  57. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  58. package/dist/features/auth/hooks/useAuth.d.ts +23 -1
  59. package/dist/features/auth/hooks/useAuth.js +27 -1
  60. package/dist/features/auth/permissions/constants.d.ts +1 -1
  61. package/dist/features/auth/permissions/constants.js +3 -0
  62. package/dist/features/auth/repository/session.repository.d.ts +16 -1
  63. package/dist/features/auth/repository/session.repository.js +23 -0
  64. package/dist/features/auth/repository/user.repository.js +13 -2
  65. package/dist/features/blog/components/BlogListView.js +2 -2
  66. package/dist/features/cart/components/CartPriceBreakdown.d.ts +11 -1
  67. package/dist/features/cart/components/CartPriceBreakdown.js +3 -3
  68. package/dist/features/cart/components/StoreAddonsPicker.d.ts +9 -0
  69. package/dist/features/cart/components/StoreAddonsPicker.js +15 -5
  70. package/dist/features/cart/components/index.d.ts +1 -1
  71. package/dist/features/cart/components/index.js +1 -1
  72. package/dist/features/cart/hooks/useGuestCartMerge.d.ts +8 -1
  73. package/dist/features/cart/hooks/useGuestCartMerge.js +2 -1
  74. package/dist/features/cart/utils/line-total.d.ts +25 -0
  75. package/dist/features/cart/utils/line-total.js +26 -0
  76. package/dist/features/catalogue/repository/catalogue.repository.d.ts +12 -0
  77. package/dist/features/catalogue/repository/catalogue.repository.js +15 -0
  78. package/dist/features/categories/components/CategoryTree.js +2 -2
  79. package/dist/features/contact/components/ContactForm.js +1 -1
  80. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +1 -1
  81. package/dist/features/copilot/hooks/useCopilotFeedback.js +1 -1
  82. package/dist/features/events/components/AdminEventEditorView.js +16 -9
  83. package/dist/features/events/components/AdminEventEntriesView.js +7 -13
  84. package/dist/features/events/components/EventRafflesSection.js +14 -2
  85. package/dist/features/events/components/EventStatusBadge.js +1 -1
  86. package/dist/features/events/components/event-entry-detail-fields.d.ts +24 -0
  87. package/dist/features/events/components/event-entry-detail-fields.js +13 -0
  88. package/dist/features/faq/components/ContactCTA.js +1 -1
  89. package/dist/features/faq/components/FAQHelpfulButtons.js +2 -2
  90. package/dist/features/faq/repository/faqs.repository.d.ts +16 -1
  91. package/dist/features/faq/repository/faqs.repository.js +64 -54
  92. package/dist/features/faq/schemas/firestore.d.ts +3 -2
  93. package/dist/features/faq/schemas/firestore.js +2 -2
  94. package/dist/features/faq/schemas/index.d.ts +3 -3
  95. package/dist/features/faq/schemas/index.js +1 -1
  96. package/dist/features/filters/FilterFacetSection.js +2 -2
  97. package/dist/features/forms/Toggle.js +1 -1
  98. package/dist/features/grouped/components/GroupedListingEditorView.d.ts +18 -0
  99. package/dist/features/grouped/components/GroupedListingEditorView.js +192 -0
  100. package/dist/features/homepage/components/AdvertisementBanner.js +2 -2
  101. package/dist/features/homepage/components/BrandsSection.js +1 -1
  102. package/dist/features/homepage/components/CTABannerSection.js +1 -1
  103. package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +1 -1
  104. package/dist/features/homepage/components/HomepageSectionBoundary.d.ts +39 -0
  105. package/dist/features/homepage/components/HomepageSectionBoundary.js +49 -0
  106. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  107. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  108. package/dist/features/homepage/lib/section-renderer.js +2 -1
  109. package/dist/features/layout/AppLayoutShell.js +5 -5
  110. package/dist/features/layout/BottomActions.js +5 -5
  111. package/dist/features/layout/BottomSheet.js +1 -1
  112. package/dist/features/layout/MainNavbar.js +1 -1
  113. package/dist/features/layout/NavbarLayout.js +1 -1
  114. package/dist/features/layout/TitleBarLayout.js +3 -3
  115. package/dist/features/layout/hooks/useFormBottomActions.d.ts +11 -1
  116. package/dist/features/layout/hooks/useFormBottomActions.js +16 -2
  117. package/dist/features/lottery/schemas/config-write.d.ts +141 -0
  118. package/dist/features/lottery/schemas/config-write.js +139 -0
  119. package/dist/features/media/MediaPickerModal.js +3 -1
  120. package/dist/features/media/MediaVideo.js +4 -2
  121. package/dist/features/media/components/MediaSlider.js +1 -1
  122. package/dist/features/orders/components/OrdersList.js +2 -2
  123. package/dist/features/payments/repository/payout.repository.d.ts +11 -1
  124. package/dist/features/payments/repository/payout.repository.js +14 -6
  125. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
  126. package/dist/features/pre-orders/components/PreOrderBadge.js +1 -1
  127. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -1
  128. package/dist/features/products/columns/productTableColumns.js +3 -3
  129. package/dist/features/products/components/CompareOverlay.js +1 -1
  130. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
  131. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  132. package/dist/features/products/components/PrizeDrawsSection.js +5 -1
  133. package/dist/features/products/components/ProductDetailActions.js +11 -7
  134. package/dist/features/products/components/ProductDetailPageView.js +3 -3
  135. package/dist/features/products/components/ProductFeatureBadges.js +2 -2
  136. package/dist/features/products/components/ProductFeaturesSelector.js +1 -1
  137. package/dist/features/products/components/ProductFilters.js +15 -8
  138. package/dist/features/products/components/ProductGrid.js +8 -17
  139. package/dist/features/products/components/ProductTabs.js +7 -4
  140. package/dist/features/products/components/ProductTabsShell.js +2 -7
  141. package/dist/features/products/constants/action-defs.d.ts +21 -4
  142. package/dist/features/products/constants/action-defs.js +7 -3
  143. package/dist/features/products/hooks/useRelatedProducts.js +2 -1
  144. package/dist/features/products/repository/products.repository.d.ts +1 -1
  145. package/dist/features/products/repository/products.repository.js +27 -13
  146. package/dist/features/products/schemas/firestore.d.ts +3 -2
  147. package/dist/features/products/schemas/firestore.js +1 -1
  148. package/dist/features/reviews/actions/review-actions.d.ts +8 -1
  149. package/dist/features/reviews/actions/review-actions.js +14 -3
  150. package/dist/features/reviews/components/ReviewDetailShell.js +10 -4
  151. package/dist/features/reviews/components/ReviewModal.js +1 -1
  152. package/dist/features/reviews/repository/reviews.repository.d.ts +25 -1
  153. package/dist/features/reviews/repository/reviews.repository.js +53 -13
  154. package/dist/features/reviews/schemas/firestore.d.ts +6 -0
  155. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  156. package/dist/features/search/components/Search.js +3 -3
  157. package/dist/features/search/components/SearchResultsSection.js +1 -1
  158. package/dist/features/seller/components/SellerAddressesView.js +3 -1
  159. package/dist/features/seller/components/SellerBidsView.js +6 -1
  160. package/dist/features/seller/components/SellerSidebar.js +1 -1
  161. package/dist/features/shell/SectionForm.d.ts +21 -1
  162. package/dist/features/shell/SectionForm.js +15 -3
  163. package/dist/features/shell/build-sections.js +10 -1
  164. package/dist/features/shell/field-ui-meta.d.ts +21 -0
  165. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +23 -6
  166. package/dist/features/site-settings/components/ActionPermissionsManager.js +1 -1
  167. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +21 -4
  168. package/dist/features/store-extensions/repository/store-extensions.repositories.js +24 -4
  169. package/dist/features/store-extensions/schemas/shipping-config-form.js +13 -0
  170. package/dist/features/store-extensions/schemas/store-category-form.js +1 -1
  171. package/dist/features/stores/actions/store-query-actions.js +25 -1
  172. package/dist/features/stores/components/InteractiveStoreCard.js +1 -1
  173. package/dist/features/stores/components/StoreHeader.js +1 -1
  174. package/dist/features/stores/components/StoreNavTabs.js +20 -8
  175. package/dist/features/support/repository/support.repository.d.ts +12 -0
  176. package/dist/features/support/repository/support.repository.js +15 -0
  177. package/dist/features/support/schemas/ticket-create-form.d.ts +33 -0
  178. package/dist/features/support/schemas/ticket-create-form.js +82 -0
  179. package/dist/features/tester/seed-data/products-tester-seed-data.js +2 -2
  180. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +138 -1
  181. package/dist/features/wishlist/components/WishlistHeartButton.d.ts +55 -0
  182. package/dist/features/wishlist/components/WishlistHeartButton.js +52 -0
  183. package/dist/features/wishlist/components/index.d.ts +2 -1
  184. package/dist/features/wishlist/components/index.js +1 -1
  185. package/dist/index.d.ts +10 -3
  186. package/dist/index.js +19 -4
  187. package/dist/monitoring/error-tracking.js +39 -5
  188. package/dist/monitoring/server-logger.js +70 -32
  189. package/dist/next/components/ErrorView.js +1 -1
  190. package/dist/next/routing/route-map.d.ts +39 -7
  191. package/dist/next/routing/route-map.js +34 -4
  192. package/dist/providers/db-firebase/base.repository.d.ts +30 -0
  193. package/dist/providers/db-firebase/base.repository.js +56 -9
  194. package/dist/react/ErrorBoundary.js +9 -1
  195. package/dist/react/contexts/SessionContext.js +6 -2
  196. package/dist/react/hooks/useAuthGate.d.ts +22 -0
  197. package/dist/react/hooks/useAuthGate.js +86 -7
  198. package/dist/schemas/registry.d.ts +3 -3
  199. package/dist/security/index.d.ts +1 -1
  200. package/dist/security/index.js +1 -1
  201. package/dist/security/pii-encrypt.d.ts +13 -5
  202. package/dist/security/pii-encrypt.js +132 -6
  203. package/dist/security/pii-schemas.d.ts +20 -4
  204. package/dist/security/pii-schemas.js +23 -5
  205. package/dist/seed/_helpers/product-search-txt.d.ts +18 -0
  206. package/dist/seed/_helpers/product-search-txt.js +33 -0
  207. package/dist/seed/faq-seed-data.d.ts +16 -0
  208. package/dist/seed/faq-seed-data.js +22 -1
  209. package/dist/seed/products-art-seed-data.js +49 -7
  210. package/dist/seed/products-auctions-seed-data.js +12 -4
  211. package/dist/seed/products-classifieds-seed-data.d.ts +5 -0
  212. package/dist/seed/products-classifieds-seed-data.js +65 -9
  213. package/dist/seed/products-digital-codes-seed-data.d.ts +5 -0
  214. package/dist/seed/products-digital-codes-seed-data.js +65 -9
  215. package/dist/seed/products-live-items-seed-data.d.ts +5 -0
  216. package/dist/seed/products-live-items-seed-data.js +33 -5
  217. package/dist/seed/products-preorders-seed-data.d.ts +1 -1
  218. package/dist/seed/products-preorders-seed-data.js +6 -2
  219. package/dist/seed/products-prize-draws-seed-data.d.ts +5 -0
  220. package/dist/seed/products-prize-draws-seed-data.js +57 -8
  221. package/dist/seed/products-standard-seed-data.js +6 -2
  222. package/dist/seed/products-stickers-seed-data.js +49 -7
  223. package/dist/seed/site-settings-seed-data.js +5 -3
  224. package/dist/seo/json-ld.d.ts +44 -2
  225. package/dist/seo/json-ld.js +57 -11
  226. package/dist/styles.css +368 -90
  227. package/dist/tailwind-utilities.css +1 -1
  228. package/dist/tokens/index.d.ts +1 -1
  229. package/dist/tokens/index.js +1 -1
  230. package/dist/tokens/themes/default-dark.js +6 -0
  231. package/dist/tokens/themes/default-light.js +5 -0
  232. package/dist/tokens/tokens.css +84 -0
  233. package/dist/ui/DataTable.js +1 -1
  234. package/dist/ui/DataTable.style.css +3 -3
  235. package/dist/ui/components/BulkActionBar.style.css +1 -3
  236. package/dist/ui/components/Button.js +15 -6
  237. package/dist/ui/components/Dropdown.style.css +1 -4
  238. package/dist/ui/components/FilterChipGroup.js +1 -1
  239. package/dist/ui/components/FilterDrawer.style.css +1 -1
  240. package/dist/ui/components/Icon.d.ts +53 -0
  241. package/dist/ui/components/Icon.js +41 -0
  242. package/dist/ui/components/IconButton.d.ts +13 -0
  243. package/dist/ui/components/IconButton.js +13 -0
  244. package/dist/ui/components/IconButton.style.css +18 -10
  245. package/dist/ui/components/ListingLayout.style.css +2 -10
  246. package/dist/ui/components/ListingToolbar.js +6 -6
  247. package/dist/ui/components/Modal.js +1 -1
  248. package/dist/ui/components/PaginatedSelect.style.css +3 -3
  249. package/dist/ui/components/Pagination.style.css +1 -6
  250. package/dist/ui/components/RichTextEditor.js +1 -1
  251. package/dist/ui/components/RowActionMenu.style.css +1 -1
  252. package/dist/ui/components/SideDrawer.style.css +1 -1
  253. package/dist/ui/components/Slider.js +1 -1
  254. package/dist/ui/components/TabBarShell.d.ts +78 -0
  255. package/dist/ui/components/TabBarShell.js +95 -0
  256. package/dist/ui/components/Tabs.js +12 -8
  257. package/dist/ui/components/Tabs.style.css +249 -41
  258. package/dist/ui/components/ViewToggle.style.css +1 -5
  259. package/dist/ui/components/tabs-overflow.d.ts +17 -0
  260. package/dist/ui/components/tabs-overflow.js +142 -0
  261. package/dist/ui/icons/icon-registry.d.ts +38 -0
  262. package/dist/ui/icons/icon-registry.js +37 -0
  263. package/dist/ui/index.d.ts +6 -0
  264. package/dist/ui/index.js +3 -0
  265. package/dist/utils/index.d.ts +1 -1
  266. package/dist/utils/index.js +1 -1
  267. package/dist/utils/media-url.d.ts +27 -4
  268. package/dist/utils/media-url.js +37 -6
  269. package/dist/utils/search-txt.d.ts +71 -0
  270. package/dist/utils/search-txt.js +118 -0
  271. package/package.json +1 -1
@@ -51,7 +51,15 @@ export class ErrorBoundary extends Component {
51
51
  componentStack: errorInfo.componentStack,
52
52
  timestamp: new Date().toISOString(),
53
53
  });
54
- trackError(error, ErrorCategory.UI, ErrorSeverity.HIGH);
54
+ // Forward componentStack — `serverErrors` declares the field and, until the
55
+ // error tracker was wired to the client-error beacon, nothing ever produced
56
+ // it. It is the fastest way to identify which subtree actually blew up.
57
+ trackError(error, ErrorCategory.UI, ErrorSeverity.HIGH, {
58
+ metadata: {
59
+ componentStack: errorInfo.componentStack ?? null,
60
+ digest: error.digest ?? null,
61
+ },
62
+ });
55
63
  this.props.onError?.(error, errorInfo);
56
64
  }
57
65
  render() {
@@ -256,8 +256,12 @@ export function SessionProvider({ children, initialUser, endpoints: endpointOver
256
256
  }
257
257
  // Server revocation and Firebase sign-out fire-and-forget (non-blocking)
258
258
  Promise.all([
259
- apiClient.post(ep.logout, {}).catch((err) => logger.error("Server logout failed", { err })),
260
- adapter.signOut().catch((err) => logger.error("Firebase sign-out failed", { err })),
259
+ apiClient
260
+ .post(ep.logout, {})
261
+ .catch((err) => logger.error("Server logout failed", { error: normalizeError(err).message })),
262
+ adapter
263
+ .signOut()
264
+ .catch((err) => logger.error("Firebase sign-out failed", { error: normalizeError(err).message })),
261
265
  ]);
262
266
  }, [ep.logout, onSignOutInvalidate, signOutInvalidationKeys]);
263
267
  useEffect(() => {
@@ -1,8 +1,30 @@
1
1
  import type { ActionId } from "../../features/products/constants/action-defs";
2
2
  export interface UseAuthGateReturn {
3
3
  requireAuth: (actionId: ActionId, fn: () => void | Promise<void>) => void;
4
+ /**
5
+ * True while the session is still resolving, or while a click is parked
6
+ * waiting on it. Drive a spinner / disabled state from this — do NOT use it
7
+ * to hide the control, or the user loses the affordance mid-page-load.
8
+ */
9
+ isAuthResolving: boolean;
4
10
  modalOpen: boolean;
5
11
  modalMessage: string;
6
12
  closeModal: () => void;
7
13
  }
14
+ /**
15
+ * The single auth/permission gate behind every CTA in the app.
16
+ *
17
+ * 🛑 `loading` is load-bearing and must never be dropped again.
18
+ *
19
+ * This hook used to read only `user` from the session. `SessionProvider` is
20
+ * mounted with `initialUser={null}` (the root locale layout is static and must
21
+ * stay that way), so on every hard load `user` is `null` and `loading` is
22
+ * `true` until Firebase persistence + a profile fetch resolve. Reading `user`
23
+ * alone made that window indistinguishable from "signed out": a signed-in user
24
+ * clicking a wishlist heart in the first ~800ms got "You need to be signed in".
25
+ *
26
+ * A click that arrives during that window is PARKED and replayed once the
27
+ * session settles — not dropped. Dropping it is indistinguishable from a dead
28
+ * button, which is the failure mode this hook is supposed to prevent.
29
+ */
8
30
  export declare function useAuthGate(): UseAuthGateReturn;
@@ -1,19 +1,57 @@
1
1
  "use client";
2
- import { useState, useCallback } from "react";
2
+ import { useState, useCallback, useRef, useEffect } from "react";
3
3
  import { useAuth } from "../contexts/SessionContext";
4
4
  import { useSiteSettings } from "../../core/hooks/useSiteSettings";
5
5
  import { ACTION_META } from "../../features/products/constants/action-defs";
6
6
  const DEFAULT_MSG = "You need to be signed in to continue.";
7
7
  const DISABLED_MSG = "This action is currently unavailable.";
8
8
  const PERMISSION_MSG = "You don't have permission to perform this action.";
9
+ /**
10
+ * Ceiling on how long a click may sit parked waiting for the session to resolve.
11
+ *
12
+ * `SessionContext` settles after Firebase persistence resolves AND one
13
+ * `/api/user/profile` round-trip returns. If that never happens (offline, a
14
+ * dead adapter) we fall back to the signed-out answer rather than eating the
15
+ * click forever — a button that never responds is worse than a wrong modal.
16
+ */
17
+ const AUTH_RESOLVE_TIMEOUT_MS = 8000;
18
+ /**
19
+ * The single auth/permission gate behind every CTA in the app.
20
+ *
21
+ * 🛑 `loading` is load-bearing and must never be dropped again.
22
+ *
23
+ * This hook used to read only `user` from the session. `SessionProvider` is
24
+ * mounted with `initialUser={null}` (the root locale layout is static and must
25
+ * stay that way), so on every hard load `user` is `null` and `loading` is
26
+ * `true` until Firebase persistence + a profile fetch resolve. Reading `user`
27
+ * alone made that window indistinguishable from "signed out": a signed-in user
28
+ * clicking a wishlist heart in the first ~800ms got "You need to be signed in".
29
+ *
30
+ * A click that arrives during that window is PARKED and replayed once the
31
+ * session settles — not dropped. Dropping it is indistinguishable from a dead
32
+ * button, which is the failure mode this hook is supposed to prevent.
33
+ */
9
34
  export function useAuthGate() {
10
- const { user } = useAuth();
35
+ const { user, loading } = useAuth();
11
36
  const { data: settings } = useSiteSettings();
12
37
  const [state, setState] = useState({ open: false, message: "" });
13
- const requireAuth = useCallback((actionId, fn) => {
38
+ const [parked, setParked] = useState(false);
39
+ const parkedRef = useRef(null);
40
+ const timerRef = useRef(null);
41
+ const clearTimer = useCallback(() => {
42
+ if (timerRef.current) {
43
+ clearTimeout(timerRef.current);
44
+ timerRef.current = null;
45
+ }
46
+ }, []);
47
+ /**
48
+ * The gate decision itself. Only ever called once the session has resolved,
49
+ * and re-created whenever `user`/`settings` change — which is what keeps a
50
+ * replayed click from being judged against a stale session.
51
+ */
52
+ const evaluate = useCallback((actionId, fn) => {
14
53
  const meta = ACTION_META[actionId];
15
- const enabled = settings
16
- ?.actionConfig?.[actionId]?.enabled ??
54
+ const enabled = settings?.actionConfig?.[actionId]?.enabled ??
17
55
  meta?.defaultEnabled ??
18
56
  true;
19
57
  if (!enabled) {
@@ -24,12 +62,53 @@ export function useAuthGate() {
24
62
  setState({ open: true, message: meta.authMessage ?? DEFAULT_MSG });
25
63
  return;
26
64
  }
27
- if (meta?.requiredPermission && !user?.permissions?.includes(meta.requiredPermission)) {
65
+ if (meta?.requiredPermission &&
66
+ !user?.permissions?.includes(meta.requiredPermission)) {
28
67
  setState({ open: true, message: PERMISSION_MSG });
29
68
  return;
30
69
  }
31
70
  void fn();
32
71
  }, [user, settings]);
72
+ const requireAuth = useCallback((actionId, fn) => {
73
+ if (loading) {
74
+ // Park only {actionId, fn} — the caller's click closure is preserved
75
+ // verbatim, while the gate's own view of the session is re-read at
76
+ // drain time via `evaluate`.
77
+ parkedRef.current = { actionId, fn };
78
+ setParked(true);
79
+ clearTimer();
80
+ timerRef.current = setTimeout(() => {
81
+ parkedRef.current = null;
82
+ setParked(false);
83
+ setState({
84
+ open: true,
85
+ message: ACTION_META[actionId]?.authMessage ?? DEFAULT_MSG,
86
+ });
87
+ }, AUTH_RESOLVE_TIMEOUT_MS);
88
+ return;
89
+ }
90
+ evaluate(actionId, fn);
91
+ }, [loading, evaluate, clearTimer]);
92
+ // Drain the parked click with a FRESH user/settings closure. The ref is
93
+ // nulled before `evaluate` runs, so an extra effect pass is a no-op.
94
+ useEffect(() => {
95
+ if (loading)
96
+ return;
97
+ const request = parkedRef.current;
98
+ if (!request)
99
+ return;
100
+ parkedRef.current = null;
101
+ clearTimer();
102
+ setParked(false);
103
+ evaluate(request.actionId, request.fn);
104
+ }, [loading, evaluate, clearTimer]);
105
+ useEffect(() => () => clearTimer(), [clearTimer]);
33
106
  const closeModal = useCallback(() => setState((s) => ({ ...s, open: false })), []);
34
- return { requireAuth, modalOpen: state.open, modalMessage: state.message, closeModal };
107
+ return {
108
+ requireAuth,
109
+ isAuthResolving: loading || parked,
110
+ modalOpen: state.open,
111
+ modalMessage: state.message,
112
+ closeModal,
113
+ };
35
114
  }
@@ -2017,7 +2017,7 @@ export declare const SCHEMAS: {
2017
2017
  order: import("zod").ZodNumber;
2018
2018
  priority: import("zod").ZodNumber;
2019
2019
  tags: import("zod").ZodArray<import("zod").ZodString, "many">;
2020
- searchTokens: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString, "many">>;
2020
+ searchTxt: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString, "many">>;
2021
2021
  relatedFAQs: import("zod").ZodArray<import("zod").ZodString, "many">;
2022
2022
  useSiteSettings: import("zod").ZodBoolean;
2023
2023
  variables: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodString>>;
@@ -2099,7 +2099,7 @@ export declare const SCHEMAS: {
2099
2099
  metaTitle?: string | undefined;
2100
2100
  metaDescription?: string | undefined;
2101
2101
  };
2102
- searchTokens?: string[] | undefined;
2102
+ searchTxt?: string[] | undefined;
2103
2103
  variables?: Record<string, string> | undefined;
2104
2104
  }, {
2105
2105
  id: string;
@@ -2138,7 +2138,7 @@ export declare const SCHEMAS: {
2138
2138
  metaTitle?: string | undefined;
2139
2139
  metaDescription?: string | undefined;
2140
2140
  };
2141
- searchTokens?: string[] | undefined;
2141
+ searchTxt?: string[] | undefined;
2142
2142
  variables?: Record<string, string> | undefined;
2143
2143
  }>;
2144
2144
  readonly groupedListings: import("zod").ZodObject<{
@@ -4,7 +4,7 @@ export type { RateLimitConfig, RateLimitResult } from "./rate-limit";
4
4
  export { requireAuth, requireRole, requireOwnership, requireEmailVerified, requireActiveAccount, canChangeRole, getRoleLevel, } from "./authorization";
5
5
  export type { UserRole } from "./authorization";
6
6
  export { redactPii, safeDisplayName, safeDisplayEmail, maskIp } from "./pii-redact";
7
- export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPiiFields, encryptPii, decryptPii, piiBlindIndex, addPiiIndices, getPiiConfigError, encryptShippingAddress, decryptShippingAddress, encryptPayoutDetails, decryptPayoutDetails, encryptShippingConfig, decryptShippingConfig, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "./pii-encrypt";
7
+ export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPiiFields, encryptPii, decryptPii, piiBlindIndex, addPiiIndices, piiIndicesFor, getPiiConfigError, encryptShippingAddress, decryptShippingAddress, encryptPayoutDetails, decryptPayoutDetails, encryptShippingConfig, decryptShippingConfig, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "./pii-encrypt";
8
8
  export { isPiiEncrypted, maskName, maskEmail, maskPublicReview, maskPublicBid, maskPublicEventEntry, maskOfferForSeller, ENC_PREFIX, HMAC_PREFIX, } from "./pii-mask";
9
9
  export { USER_PII_FIELDS, USER_PII_INDEX_MAP, ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, BID_PII_FIELDS, NEWSLETTER_PII_FIELDS, NEWSLETTER_PII_INDEX_MAP, TOKEN_PII_FIELDS, TOKEN_PII_INDEX_MAP, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, OFFER_PII_FIELDS, CHAT_PII_FIELDS, EVENT_ENTRY_PII_FIELDS, LOTTERY_ENTRY_PII_FIELDS, PAYMENT_METHOD_PII_FIELDS, } from "./pii-schemas";
10
10
  export { encryptSecret, decryptSecret, isSecretEncrypted, maskSecret, } from "./settings-encryption";
@@ -2,7 +2,7 @@ export { generateNonce, buildCSP } from "./csp";
2
2
  export { rateLimit, applyRateLimit, rateLimitByIdentifier, RateLimitPresets, clearRateLimitStore, getClientIP, hashGuestIdentity, } from "./rate-limit";
3
3
  export { requireAuth, requireRole, requireOwnership, requireEmailVerified, requireActiveAccount, canChangeRole, getRoleLevel, } from "./authorization";
4
4
  export { redactPii, safeDisplayName, safeDisplayEmail, maskIp } from "./pii-redact";
5
- export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPiiFields, encryptPii, decryptPii, piiBlindIndex, addPiiIndices, getPiiConfigError, encryptShippingAddress, decryptShippingAddress, encryptPayoutDetails, decryptPayoutDetails, encryptShippingConfig, decryptShippingConfig, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "./pii-encrypt";
5
+ export { encryptValue, decryptValue, hmacBlindIndex, encryptPiiFields, decryptPiiFields, encryptPii, decryptPii, piiBlindIndex, addPiiIndices, piiIndicesFor, getPiiConfigError, encryptShippingAddress, decryptShippingAddress, encryptPayoutDetails, decryptPayoutDetails, encryptShippingConfig, decryptShippingConfig, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "./pii-encrypt";
6
6
  // Crypto-free display-masking helpers -- sourced from pii-mask.ts (not
7
7
  // pii-encrypt.ts) so this barrel stays safe to re-export from the
8
8
  // universal client+server index.ts. See pii-mask.ts's header comment.
@@ -21,11 +21,6 @@ export declare function decryptValue(ciphertext: string): string;
21
21
  * Used for equality lookups without decrypting (e.g. find-by-email).
22
22
  */
23
23
  export declare function hmacBlindIndex(value: string): string;
24
- /**
25
- * Encrypt specified PII fields in a Firestore document.
26
- * For each encrypted field, a `<field>Index` blind-index sibling is added.
27
- * Already-encrypted values (prefix `enc:v1:`) are left unchanged.
28
- */
29
24
  export declare function encryptPiiFields<T extends object>(doc: T, piiFields: string[]): T;
30
25
  /**
31
26
  * Decrypt specified PII fields in a Firestore document.
@@ -43,6 +38,19 @@ export declare function piiBlindIndex(plaintext: string): string;
43
38
  * For each mapping entry (e.g. { email: "emailIndex" }), if the source field
44
39
  * is a non-empty string, computes the HMAC and stores it as the index field.
45
40
  */
41
+ /**
42
+ * Blind-index fields ONLY — derived from `source` (which must be plaintext),
43
+ * with none of `source`'s own keys carried along.
44
+ *
45
+ * Use this, never `addPiiIndices`, when merging indices onto an already-encrypted
46
+ * object. `addPiiIndices` returns `{...source, ...indices}`, so spreading its
47
+ * result over ciphertext silently restores the plaintext — which is exactly how
48
+ * `payouts.sellerEmail`, `payouts.upiId` and `reviews.userName` ended up stored
49
+ * in cleartext beside a valid index.
50
+ *
51
+ * @param source plaintext document — hashing ciphertext produces a useless index
52
+ */
53
+ export declare function piiIndicesFor(source: object, mapping: Record<string, string>): Record<string, string>;
46
54
  export declare function addPiiIndices<T extends object>(obj: T, mapping: Record<string, string>): T;
47
55
  /**
48
56
  * Encrypt PII inside a shipping address stored in an order.
@@ -99,17 +99,92 @@ export function hmacBlindIndex(value) {
99
99
  * For each encrypted field, a `<field>Index` blind-index sibling is added.
100
100
  * Already-encrypted values (prefix `enc:v1:`) are left unchanged.
101
101
  */
102
+ /** Read `a.b.c`, returning undefined if any hop is missing or not an object. */
103
+ function getPath(obj, path) {
104
+ return path.split(".").reduce((acc, key) => {
105
+ if (acc === null || typeof acc !== "object")
106
+ return undefined;
107
+ return acc[key];
108
+ }, obj);
109
+ }
110
+ /** Set `a.b.c`, cloning each object along the path so the input is untouched. */
111
+ function setPath(obj, path, value) {
112
+ const keys = path.split(".");
113
+ const last = keys.pop();
114
+ let cursor = obj;
115
+ for (const key of keys) {
116
+ const next = cursor[key];
117
+ if (next === null || typeof next !== "object")
118
+ return;
119
+ cursor[key] = { ...next };
120
+ cursor = cursor[key];
121
+ }
122
+ cursor[last] = value;
123
+ }
124
+ /**
125
+ * Encrypt the named fields, adding a blind index alongside each top-level one.
126
+ *
127
+ * Supports dotted paths (`deviceInfo.ip`, `whatsappConfig.accessToken`). This
128
+ * was a flat top-level loop that skipped anything non-string, which is why
129
+ * nested PII stayed in plaintext — and why the exported
130
+ * `STORE_SECRET_FIELDS = ["whatsappConfig.accessToken"]` was unusable by the
131
+ * very function it was written for.
132
+ *
133
+ * Idempotent: a value already carrying `enc:v1:` is left alone, so re-running
134
+ * over a partially-migrated document is safe.
135
+ */
136
+ /**
137
+ * Encrypt `itemPath` on every element of the array at `arrayPath`.
138
+ *
139
+ * Spelled `messages[].body` in a PII field list. Without this, array-of-object
140
+ * PII — conversation message bodies, status-history notes — is written in
141
+ * plaintext and `mapDoc`'s decrypt never touches it on the way back out: a leak
142
+ * with no error and no visible symptom.
143
+ */
144
+ function encryptInArray(root, arrayPath, itemPath) {
145
+ const arr = getPath(root, arrayPath);
146
+ if (!Array.isArray(arr))
147
+ return;
148
+ const next = arr.map((el) => {
149
+ if (el === null || typeof el !== "object")
150
+ return el;
151
+ const clone = { ...el };
152
+ const value = itemPath ? getPath(clone, itemPath) : undefined;
153
+ if (typeof value !== "string" || !value || value.startsWith(ENC_PREFIX)) {
154
+ return clone;
155
+ }
156
+ setPath(clone, itemPath, encryptValue(value));
157
+ return clone;
158
+ });
159
+ setPath(root, arrayPath, next);
160
+ }
102
161
  export function encryptPiiFields(doc, piiFields) {
103
162
  const result = { ...doc };
104
- const source = doc;
105
163
  for (const field of piiFields) {
106
- const value = source[field];
164
+ // `messages[].body` — encrypt one property on every element of an array.
165
+ if (field.includes("[]")) {
166
+ const [arrayPath, rest] = field.split("[]");
167
+ encryptInArray(result, arrayPath, rest.replace(/^\./, ""));
168
+ continue;
169
+ }
170
+ const nested = field.includes(".");
171
+ const value = nested
172
+ ? getPath(result, field)
173
+ : result[field];
107
174
  if (typeof value !== "string" || !value)
108
175
  continue;
109
176
  if (value.startsWith(ENC_PREFIX))
110
177
  continue; // already encrypted
111
- result[field] = encryptValue(value);
112
- result[`${field}Index`] = hmacBlindIndex(value);
178
+ if (nested) {
179
+ // Blind index deliberately omitted for nested paths: `deviceInfo.ipIndex`
180
+ // would be a nested sibling nothing queries, and an index only earns its
181
+ // storage when some lookup actually uses it.
182
+ setPath(result, field, encryptValue(value));
183
+ }
184
+ else {
185
+ result[field] = encryptValue(value);
186
+ result[`${field}Index`] = hmacBlindIndex(value);
187
+ }
113
188
  }
114
189
  return result;
115
190
  }
@@ -119,9 +194,37 @@ export function encryptPiiFields(doc, piiFields) {
119
194
  */
120
195
  export function decryptPiiFields(doc, piiFields) {
121
196
  const result = { ...doc };
122
- const source = doc;
123
197
  for (const field of piiFields) {
124
- const value = source[field];
198
+ // MUST mirror encryptPiiFields' path handling. This was a flat top-level
199
+ // loop while encrypt understood dotted and array paths — so a nested field
200
+ // would be encrypted on write and never decrypted on read, surfacing
201
+ // ciphertext to callers. Any path form added to one must be added to both.
202
+ if (field.includes("[]")) {
203
+ const [arrayPath, rest] = field.split("[]");
204
+ const itemPath = rest.replace(/^\./, "");
205
+ const arr = getPath(result, arrayPath);
206
+ if (!Array.isArray(arr))
207
+ continue;
208
+ setPath(result, arrayPath, arr.map((el) => {
209
+ if (el === null || typeof el !== "object")
210
+ return el;
211
+ const clone = { ...el };
212
+ const value = itemPath ? getPath(clone, itemPath) : undefined;
213
+ if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
214
+ setPath(clone, itemPath, decryptValue(value));
215
+ }
216
+ return clone;
217
+ }));
218
+ continue;
219
+ }
220
+ if (field.includes(".")) {
221
+ const value = getPath(result, field);
222
+ if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
223
+ setPath(result, field, decryptValue(value));
224
+ }
225
+ continue;
226
+ }
227
+ const value = result[field];
125
228
  if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
126
229
  result[field] = decryptValue(value);
127
230
  }
@@ -152,6 +255,29 @@ export function piiBlindIndex(plaintext) {
152
255
  * For each mapping entry (e.g. { email: "emailIndex" }), if the source field
153
256
  * is a non-empty string, computes the HMAC and stores it as the index field.
154
257
  */
258
+ /**
259
+ * Blind-index fields ONLY — derived from `source` (which must be plaintext),
260
+ * with none of `source`'s own keys carried along.
261
+ *
262
+ * Use this, never `addPiiIndices`, when merging indices onto an already-encrypted
263
+ * object. `addPiiIndices` returns `{...source, ...indices}`, so spreading its
264
+ * result over ciphertext silently restores the plaintext — which is exactly how
265
+ * `payouts.sellerEmail`, `payouts.upiId` and `reviews.userName` ended up stored
266
+ * in cleartext beside a valid index.
267
+ *
268
+ * @param source plaintext document — hashing ciphertext produces a useless index
269
+ */
270
+ export function piiIndicesFor(source, mapping) {
271
+ const access = source;
272
+ const indices = {};
273
+ for (const [sourceField, indexField] of Object.entries(mapping)) {
274
+ const val = access[sourceField];
275
+ if (typeof val === "string" && val) {
276
+ indices[indexField] = piiBlindIndex(val);
277
+ }
278
+ }
279
+ return indices;
280
+ }
155
281
  export function addPiiIndices(obj, mapping) {
156
282
  const result = { ...obj };
157
283
  const access = result;
@@ -16,14 +16,30 @@ export declare const ORDER_PII_FIELDS: readonly ["userName", "userEmail", "selle
16
16
  export declare const PAYOUT_PII_FIELDS: readonly ["sellerEmail", "upiId"];
17
17
  /** Blind-index mapping for queryable payout PII */
18
18
  export declare const PAYOUT_PII_INDEX_MAP: Record<string, string>;
19
- /** PII fields in bids */
20
- export declare const BID_PII_FIELDS: readonly [];
19
+ /**
20
+ * PII fields in bids.
21
+ *
22
+ * `userEmail` only. `userName` is deliberately NOT encrypted: the seller bid
23
+ * search (`/api/store/bids`) does a prefix match on it, and encryption and
24
+ * partial-match search are mutually exclusive over the same field — ciphertext
25
+ * has no usable prefix. Encrypting it would silently break that search.
26
+ * Bidder names are already masked on the public side by `maskPublicBid`.
27
+ */
28
+ export declare const BID_PII_FIELDS: readonly ["userEmail"];
21
29
  /** PII fields in newsletter subscribers */
22
30
  export declare const NEWSLETTER_PII_FIELDS: readonly [];
23
31
  /** Blind-index mapping for newsletter subscribers */
24
32
  export declare const NEWSLETTER_PII_INDEX_MAP: Record<string, string>;
25
- /** PII fields in tokens (email verification / password reset) */
26
- export declare const TOKEN_PII_FIELDS: readonly [];
33
+ /**
34
+ * PII fields in tokens (email verification / password reset).
35
+ *
36
+ * Was an empty array, so the encrypt/decrypt machinery wrapped around these
37
+ * repositories was a no-op and every verification/reset email sat in cleartext.
38
+ * Safe to enable because `findByEmail` already queries the blind index first
39
+ * and falls back to plaintext equality, so legacy rows keep resolving while new
40
+ * ones are encrypted.
41
+ */
42
+ export declare const TOKEN_PII_FIELDS: readonly ["email"];
27
43
  /** Blind-index mapping for token email queries */
28
44
  export declare const TOKEN_PII_INDEX_MAP: Record<string, string>;
29
45
  /** PII fields in reviews */
@@ -30,16 +30,34 @@ export const PAYOUT_PII_FIELDS = ["sellerEmail", "upiId"];
30
30
  export const PAYOUT_PII_INDEX_MAP = {
31
31
  sellerEmail: "sellerEmailIndex",
32
32
  };
33
- /** PII fields in bids */
34
- export const BID_PII_FIELDS = [];
33
+ /**
34
+ * PII fields in bids.
35
+ *
36
+ * `userEmail` only. `userName` is deliberately NOT encrypted: the seller bid
37
+ * search (`/api/store/bids`) does a prefix match on it, and encryption and
38
+ * partial-match search are mutually exclusive over the same field — ciphertext
39
+ * has no usable prefix. Encrypting it would silently break that search.
40
+ * Bidder names are already masked on the public side by `maskPublicBid`.
41
+ */
42
+ export const BID_PII_FIELDS = ["userEmail"];
35
43
  /** PII fields in newsletter subscribers */
36
44
  export const NEWSLETTER_PII_FIELDS = [];
37
45
  /** Blind-index mapping for newsletter subscribers */
38
46
  export const NEWSLETTER_PII_INDEX_MAP = {};
39
- /** PII fields in tokens (email verification / password reset) */
40
- export const TOKEN_PII_FIELDS = [];
47
+ /**
48
+ * PII fields in tokens (email verification / password reset).
49
+ *
50
+ * Was an empty array, so the encrypt/decrypt machinery wrapped around these
51
+ * repositories was a no-op and every verification/reset email sat in cleartext.
52
+ * Safe to enable because `findByEmail` already queries the blind index first
53
+ * and falls back to plaintext equality, so legacy rows keep resolving while new
54
+ * ones are encrypted.
55
+ */
56
+ export const TOKEN_PII_FIELDS = ["email"];
41
57
  /** Blind-index mapping for token email queries */
42
- export const TOKEN_PII_INDEX_MAP = {};
58
+ export const TOKEN_PII_INDEX_MAP = {
59
+ email: "emailIndex",
60
+ };
43
61
  /** PII fields in reviews */
44
62
  export const REVIEW_PII_FIELDS = ["userName"];
45
63
  /** Blind-index mapping for queryable review PII */
@@ -0,0 +1,18 @@
1
+ import type { ProductDocument } from "../../features/products/schemas";
2
+ /**
3
+ * Derive `searchTxt` for a seeded product from its own content.
4
+ *
5
+ * Applied to the WHOLE array at each file's export rather than written per
6
+ * record. Four of the product seed files built records as inline literals with
7
+ * an inline `searchTxt:`, and in every one of them the last record — the
8
+ * "sold"/"depleted" fixture appended at the bottom — had been added without it.
9
+ * Those are exactly the rows the "Sold & Ended" scope exists to exercise, so
10
+ * they were invisible to search in the one view built to show them.
11
+ *
12
+ * The `...p` spread comes FIRST so a record cannot override or omit the field:
13
+ * that ordering is the actual guarantee, not the convention around it.
14
+ *
15
+ * Pure function of the record's content — deterministic, so this does not
16
+ * reintroduce the `appkit-seed` idempotency problem of Root Cause #25.
17
+ */
18
+ export declare function withProductSearchTxt(p: Partial<ProductDocument>): Partial<ProductDocument>;
@@ -0,0 +1,33 @@
1
+ import { buildSearchTxt } from "../../utils/search-txt";
2
+ /**
3
+ * Derive `searchTxt` for a seeded product from its own content.
4
+ *
5
+ * Applied to the WHOLE array at each file's export rather than written per
6
+ * record. Four of the product seed files built records as inline literals with
7
+ * an inline `searchTxt:`, and in every one of them the last record — the
8
+ * "sold"/"depleted" fixture appended at the bottom — had been added without it.
9
+ * Those are exactly the rows the "Sold & Ended" scope exists to exercise, so
10
+ * they were invisible to search in the one view built to show them.
11
+ *
12
+ * The `...p` spread comes FIRST so a record cannot override or omit the field:
13
+ * that ordering is the actual guarantee, not the convention around it.
14
+ *
15
+ * Pure function of the record's content — deterministic, so this does not
16
+ * reintroduce the `appkit-seed` idempotency problem of Root Cause #25.
17
+ */
18
+ export function withProductSearchTxt(p) {
19
+ return {
20
+ ...p,
21
+ searchTxt: buildSearchTxt([
22
+ p.title,
23
+ p.description,
24
+ p.brand,
25
+ p.brandSlug,
26
+ p.categoryNames,
27
+ p.tags,
28
+ p.features,
29
+ p.condition,
30
+ p.specifications?.map((s) => `${s.name} ${s.value}`),
31
+ ]),
32
+ };
33
+ }
@@ -1,2 +1,18 @@
1
1
  import type { FAQDocument } from "../features/faq/schemas";
2
+ /**
3
+ * Derive `searchTxt` for every record from its own content.
4
+ *
5
+ * This is why FAQ search returned nothing: the seed writes documents raw via
6
+ * `batch.set()` (both `seed/runner.ts` and `seed-cli.mjs`), bypassing
7
+ * `FirebaseFAQsRepository.create()` — the only thing that built tokens. So all
8
+ * 63 seeded FAQs had no `searchTokens` field at all and `array-contains`
9
+ * matched zero documents, for every query, forever.
10
+ *
11
+ * Derived here rather than hand-written per record so a new FAQ cannot be added
12
+ * without tokens, and computed with the SAME `buildFaqSearchTxt` the repository
13
+ * uses so seeded and admin-created FAQs are byte-identical.
14
+ *
15
+ * Pure function of the record's own content — deterministic, so this does not
16
+ * reintroduce the `appkit-seed` idempotency problem of Root Cause #25.
17
+ */
2
18
  export declare const faqSeedData: Partial<FAQDocument>[];
@@ -16,11 +16,12 @@
16
16
  * @tag consumers:seed/index.ts,seed/runner.ts
17
17
  * @tag sideEffects:none
18
18
  */
19
+ import { buildFaqSearchTxt } from "../features/faq/repository/faqs.repository";
19
20
  const NOW = new Date();
20
21
  const daysAgo = (n) => new Date(NOW.getTime() - n * 86400000);
21
22
  const html = (text) => ({ text, format: "html" });
22
23
  const makeStats = (views = 0, helpful = 0) => ({ views, helpful, notHelpful: 0 });
23
- export const faqSeedData = [
24
+ const faqSeedRecords = [
24
25
  // ── GENERAL (10) ────────────────────────────────────────────────────────────
25
26
  {
26
27
  id: "faq-what-is-letitrip",
@@ -1234,3 +1235,23 @@ export const faqSeedData = [
1234
1235
  updatedAt: daysAgo(1),
1235
1236
  },
1236
1237
  ];
1238
+ /**
1239
+ * Derive `searchTxt` for every record from its own content.
1240
+ *
1241
+ * This is why FAQ search returned nothing: the seed writes documents raw via
1242
+ * `batch.set()` (both `seed/runner.ts` and `seed-cli.mjs`), bypassing
1243
+ * `FirebaseFAQsRepository.create()` — the only thing that built tokens. So all
1244
+ * 63 seeded FAQs had no `searchTokens` field at all and `array-contains`
1245
+ * matched zero documents, for every query, forever.
1246
+ *
1247
+ * Derived here rather than hand-written per record so a new FAQ cannot be added
1248
+ * without tokens, and computed with the SAME `buildFaqSearchTxt` the repository
1249
+ * uses so seeded and admin-created FAQs are byte-identical.
1250
+ *
1251
+ * Pure function of the record's own content — deterministic, so this does not
1252
+ * reintroduce the `appkit-seed` idempotency problem of Root Cause #25.
1253
+ */
1254
+ export const faqSeedData = faqSeedRecords.map((faq) => ({
1255
+ ...faq,
1256
+ searchTxt: buildFaqSearchTxt(faq),
1257
+ }));