@mohasinac/appkit 4.27.0 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/dist/_internal/client/features/lottery/LotteryAdminEditView.d.ts +16 -16
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +29 -6
  3. package/dist/_internal/client/features/lottery/LotteryListView.js +13 -1
  4. package/dist/_internal/server/features/checkout/actions.js +6 -4
  5. package/dist/_internal/server/features/classified/metadata.js +3 -1
  6. package/dist/_internal/server/features/digital-code/metadata.js +3 -1
  7. package/dist/_internal/server/features/live/metadata.js +3 -1
  8. package/dist/_internal/server/features/refunds/actions.js +3 -2
  9. package/dist/_internal/server/features/reviews/actions.js +3 -2
  10. package/dist/_internal/server/features/seo/sitemap.js +189 -40
  11. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/stores/adapters.js +5 -0
  13. package/dist/_internal/server/jobs/core/listingProcessor.d.ts +0 -1
  14. package/dist/_internal/server/jobs/core/listingProcessor.js +19 -1
  15. package/dist/_internal/shared/actions/action-registry.js +14 -0
  16. package/dist/_internal/shared/checkout/order-math.d.ts +25 -3
  17. package/dist/_internal/shared/features/checkout/config.d.ts +3 -3
  18. package/dist/_internal/shared/features/checkout/config.js +6 -3
  19. package/dist/client.d.ts +32 -2
  20. package/dist/client.js +37 -2
  21. package/dist/constants/api-endpoints.d.ts +21 -3
  22. package/dist/constants/api-endpoints.js +14 -1
  23. package/dist/constants/field-names.d.ts +2 -2
  24. package/dist/constants/field-names.js +2 -2
  25. package/dist/core/newsletter.repository.js +14 -1
  26. package/dist/features/about/components/FAQPageView.js +1 -1
  27. package/dist/features/about/components/FeesView.js +1 -1
  28. package/dist/features/account/components/AddressForm.d.ts +2 -0
  29. package/dist/features/account/components/AddressForm.js +5 -1
  30. package/dist/features/account/components/UserBidsView.js +1 -1
  31. package/dist/features/account/components/UserSidebar.js +1 -1
  32. package/dist/features/account/components/UserSupportView.js +24 -2
  33. package/dist/features/account/hooks/useAddresses.d.ts +29 -1
  34. package/dist/features/account/hooks/useAddresses.js +4 -1
  35. package/dist/features/account/hooks/useProfile.d.ts +1 -1
  36. package/dist/features/account/hooks/useProfile.js +1 -1
  37. package/dist/features/addresses/repository/addresses.repository.d.ts +13 -0
  38. package/dist/features/addresses/repository/addresses.repository.js +19 -0
  39. package/dist/features/admin/actions/chat-actions.js +5 -1
  40. package/dist/features/admin/components/AdminAdsView.js +1 -1
  41. package/dist/features/admin/components/AdminBidsView.js +6 -1
  42. package/dist/features/admin/components/AdminCarouselGroupEditorView.d.ts +9 -1
  43. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +42 -10
  44. package/dist/features/admin/components/AdminCarouselView.js +1 -1
  45. package/dist/features/admin/components/AdminContentGuideView.js +1 -1
  46. package/dist/features/admin/components/AdminFeatureEditorView.js +1 -1
  47. package/dist/features/admin/components/AdminGroupedListingsView.js +14 -2
  48. package/dist/features/admin/components/AdminSidebar.js +1 -1
  49. package/dist/features/admin/components/AdminViewCards.js +2 -2
  50. package/dist/features/admin/components/DataTable.js +2 -2
  51. package/dist/features/admin/schemas/firestore.js +9 -3
  52. package/dist/features/auctions/actions/bid-actions.js +45 -4
  53. package/dist/features/auctions/components/AuctionBottomActions.d.ts +21 -6
  54. package/dist/features/auctions/components/AuctionBottomActions.js +42 -11
  55. package/dist/features/auctions/components/AuctionDetailPageView.d.ts +8 -3
  56. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -2
  57. package/dist/features/auctions/components/MarketplaceAuctionCard.js +4 -5
  58. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +27 -2
  59. package/dist/features/auctions/components/PlaceBidFormClient.js +42 -20
  60. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  61. package/dist/features/auth/hooks/useAuth.d.ts +23 -1
  62. package/dist/features/auth/hooks/useAuth.js +27 -1
  63. package/dist/features/auth/permissions/constants.d.ts +1 -1
  64. package/dist/features/auth/permissions/constants.js +3 -0
  65. package/dist/features/auth/repository/session.repository.d.ts +16 -1
  66. package/dist/features/auth/repository/session.repository.js +23 -0
  67. package/dist/features/auth/repository/user.repository.js +13 -2
  68. package/dist/features/blog/components/BlogListView.js +2 -2
  69. package/dist/features/blog/repository/blog.repository.d.ts +4 -0
  70. package/dist/features/blog/repository/blog.repository.js +1 -0
  71. package/dist/features/blog/schemas/firestore.d.ts +3 -1
  72. package/dist/features/blog/schemas/firestore.js +1 -0
  73. package/dist/features/cart/components/CartPriceBreakdown.d.ts +11 -1
  74. package/dist/features/cart/components/CartPriceBreakdown.js +3 -3
  75. package/dist/features/cart/components/StoreAddonsPicker.d.ts +9 -0
  76. package/dist/features/cart/components/StoreAddonsPicker.js +15 -5
  77. package/dist/features/cart/components/index.d.ts +1 -1
  78. package/dist/features/cart/components/index.js +1 -1
  79. package/dist/features/cart/hooks/useGuestCartMerge.d.ts +8 -1
  80. package/dist/features/cart/hooks/useGuestCartMerge.js +2 -1
  81. package/dist/features/cart/utils/line-total.d.ts +25 -0
  82. package/dist/features/cart/utils/line-total.js +26 -0
  83. package/dist/features/catalogue/repository/catalogue.repository.d.ts +12 -0
  84. package/dist/features/catalogue/repository/catalogue.repository.js +15 -0
  85. package/dist/features/categories/components/CategoryTree.js +2 -2
  86. package/dist/features/contact/components/ContactForm.js +1 -1
  87. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +1 -1
  88. package/dist/features/copilot/hooks/useCopilotFeedback.js +1 -1
  89. package/dist/features/events/components/AdminEventEditorView.js +16 -9
  90. package/dist/features/events/components/AdminEventEntriesView.js +7 -13
  91. package/dist/features/events/components/EventRafflesSection.js +14 -2
  92. package/dist/features/events/components/EventStatusBadge.js +1 -1
  93. package/dist/features/events/components/event-entry-detail-fields.d.ts +24 -0
  94. package/dist/features/events/components/event-entry-detail-fields.js +13 -0
  95. package/dist/features/events/repository/events.repository.js +1 -0
  96. package/dist/features/events/schemas/firestore.d.ts +3 -1
  97. package/dist/features/events/schemas/firestore.js +1 -0
  98. package/dist/features/faq/components/ContactCTA.js +1 -1
  99. package/dist/features/faq/components/FAQHelpfulButtons.js +2 -2
  100. package/dist/features/faq/repository/faqs.repository.d.ts +16 -1
  101. package/dist/features/faq/repository/faqs.repository.js +64 -54
  102. package/dist/features/faq/schemas/firestore.d.ts +3 -2
  103. package/dist/features/faq/schemas/firestore.js +2 -2
  104. package/dist/features/faq/schemas/index.d.ts +3 -3
  105. package/dist/features/faq/schemas/index.js +1 -1
  106. package/dist/features/filters/FilterFacetSection.js +2 -2
  107. package/dist/features/forms/Toggle.js +1 -1
  108. package/dist/features/grouped/components/GroupedListingEditorView.d.ts +18 -0
  109. package/dist/features/grouped/components/GroupedListingEditorView.js +192 -0
  110. package/dist/features/homepage/components/AdvertisementBanner.js +2 -2
  111. package/dist/features/homepage/components/BrandsSection.js +1 -1
  112. package/dist/features/homepage/components/CTABannerSection.js +1 -1
  113. package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +1 -1
  114. package/dist/features/homepage/components/HomepageSectionBoundary.d.ts +39 -0
  115. package/dist/features/homepage/components/HomepageSectionBoundary.js +49 -0
  116. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  117. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  118. package/dist/features/homepage/lib/section-renderer.js +2 -1
  119. package/dist/features/layout/AppLayoutShell.js +5 -5
  120. package/dist/features/layout/BottomActions.js +5 -5
  121. package/dist/features/layout/BottomSheet.js +1 -1
  122. package/dist/features/layout/MainNavbar.js +1 -1
  123. package/dist/features/layout/NavbarLayout.js +1 -1
  124. package/dist/features/layout/TitleBarLayout.js +3 -3
  125. package/dist/features/layout/hooks/useFormBottomActions.d.ts +11 -1
  126. package/dist/features/layout/hooks/useFormBottomActions.js +16 -2
  127. package/dist/features/lottery/schemas/config-write.d.ts +141 -0
  128. package/dist/features/lottery/schemas/config-write.js +139 -0
  129. package/dist/features/media/MediaPickerModal.js +3 -1
  130. package/dist/features/media/MediaVideo.js +4 -2
  131. package/dist/features/media/components/MediaSlider.js +1 -1
  132. package/dist/features/messages/repository/conversations.repository.js +15 -5
  133. package/dist/features/orders/components/OrdersList.js +2 -2
  134. package/dist/features/payments/repository/payout.repository.d.ts +11 -1
  135. package/dist/features/payments/repository/payout.repository.js +14 -6
  136. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
  137. package/dist/features/pre-orders/components/PreOrderBadge.js +1 -1
  138. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -1
  139. package/dist/features/products/columns/productTableColumns.js +3 -3
  140. package/dist/features/products/components/CompareOverlay.js +1 -1
  141. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
  142. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  143. package/dist/features/products/components/PrizeDrawsSection.js +5 -1
  144. package/dist/features/products/components/ProductDetailActions.js +11 -7
  145. package/dist/features/products/components/ProductDetailPageView.js +3 -3
  146. package/dist/features/products/components/ProductFeatureBadges.js +2 -2
  147. package/dist/features/products/components/ProductFeaturesSelector.js +1 -1
  148. package/dist/features/products/components/ProductFilters.js +15 -8
  149. package/dist/features/products/components/ProductGrid.js +8 -17
  150. package/dist/features/products/components/ProductTabs.js +7 -4
  151. package/dist/features/products/components/ProductTabsShell.js +2 -7
  152. package/dist/features/products/constants/action-defs.d.ts +21 -4
  153. package/dist/features/products/constants/action-defs.js +7 -3
  154. package/dist/features/products/hooks/useRelatedProducts.js +2 -1
  155. package/dist/features/products/repository/products.repository.d.ts +1 -1
  156. package/dist/features/products/repository/products.repository.js +38 -16
  157. package/dist/features/products/schemas/firestore.d.ts +3 -2
  158. package/dist/features/products/schemas/firestore.js +1 -1
  159. package/dist/features/reviews/actions/review-actions.d.ts +8 -1
  160. package/dist/features/reviews/actions/review-actions.js +14 -3
  161. package/dist/features/reviews/components/ReviewDetailShell.js +10 -4
  162. package/dist/features/reviews/components/ReviewModal.js +1 -1
  163. package/dist/features/reviews/repository/reviews.repository.d.ts +29 -1
  164. package/dist/features/reviews/repository/reviews.repository.js +54 -13
  165. package/dist/features/reviews/schemas/firestore.d.ts +9 -1
  166. package/dist/features/reviews/schemas/firestore.js +1 -0
  167. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  168. package/dist/features/search/components/Search.js +3 -3
  169. package/dist/features/search/components/SearchResultsSection.js +1 -1
  170. package/dist/features/seller/components/SellerAddressesView.js +3 -1
  171. package/dist/features/seller/components/SellerBidsView.js +6 -1
  172. package/dist/features/seller/components/SellerSidebar.js +1 -1
  173. package/dist/features/shell/SectionForm.d.ts +21 -1
  174. package/dist/features/shell/SectionForm.js +15 -3
  175. package/dist/features/shell/build-sections.js +10 -1
  176. package/dist/features/shell/field-ui-meta.d.ts +21 -0
  177. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +23 -6
  178. package/dist/features/site-settings/components/ActionPermissionsManager.js +1 -1
  179. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +21 -4
  180. package/dist/features/store-extensions/repository/store-extensions.repositories.js +24 -4
  181. package/dist/features/store-extensions/schemas/shipping-config-form.js +13 -0
  182. package/dist/features/store-extensions/schemas/store-category-form.js +1 -1
  183. package/dist/features/stores/actions/store-query-actions.js +25 -1
  184. package/dist/features/stores/components/InteractiveStoreCard.js +1 -1
  185. package/dist/features/stores/components/StoreHeader.js +1 -1
  186. package/dist/features/stores/components/StoreNavTabs.js +20 -8
  187. package/dist/features/stores/repository/store.repository.js +1 -0
  188. package/dist/features/stores/schemas/firestore.d.ts +2 -0
  189. package/dist/features/support/repository/support.repository.d.ts +12 -0
  190. package/dist/features/support/repository/support.repository.js +15 -0
  191. package/dist/features/support/schemas/ticket-create-form.d.ts +33 -0
  192. package/dist/features/support/schemas/ticket-create-form.js +82 -0
  193. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +0 -1
  194. package/dist/features/tester/repository/tester-checklist-item.repository.js +6 -15
  195. package/dist/features/tester/schemas/firestore.d.ts +5 -4
  196. package/dist/features/tester/schemas/firestore.js +3 -3
  197. package/dist/features/tester/seed-data/products-tester-seed-data.js +2 -2
  198. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +138 -1
  199. package/dist/features/wishlist/components/WishlistHeartButton.d.ts +55 -0
  200. package/dist/features/wishlist/components/WishlistHeartButton.js +52 -0
  201. package/dist/features/wishlist/components/index.d.ts +2 -1
  202. package/dist/features/wishlist/components/index.js +1 -1
  203. package/dist/index.d.ts +10 -3
  204. package/dist/index.js +19 -4
  205. package/dist/monitoring/error-tracking.js +39 -5
  206. package/dist/monitoring/server-logger.js +70 -32
  207. package/dist/next/components/ErrorView.js +1 -1
  208. package/dist/next/routing/route-map.d.ts +39 -7
  209. package/dist/next/routing/route-map.js +34 -4
  210. package/dist/providers/db-firebase/base.repository.d.ts +30 -0
  211. package/dist/providers/db-firebase/base.repository.js +56 -9
  212. package/dist/react/ErrorBoundary.js +9 -1
  213. package/dist/react/contexts/SessionContext.js +6 -2
  214. package/dist/react/hooks/useAuthGate.d.ts +22 -0
  215. package/dist/react/hooks/useAuthGate.js +86 -7
  216. package/dist/schemas/registry.d.ts +3 -3
  217. package/dist/security/index.d.ts +1 -1
  218. package/dist/security/index.js +1 -1
  219. package/dist/security/pii-encrypt.d.ts +13 -5
  220. package/dist/security/pii-encrypt.js +132 -6
  221. package/dist/security/pii-schemas.d.ts +32 -4
  222. package/dist/security/pii-schemas.js +38 -5
  223. package/dist/seed/_helpers/product-search-txt.d.ts +18 -0
  224. package/dist/seed/_helpers/product-search-txt.js +33 -0
  225. package/dist/seed/_helpers/search-txt-wrappers.d.ts +20 -0
  226. package/dist/seed/_helpers/search-txt-wrappers.js +73 -0
  227. package/dist/seed/blog-posts-seed-data.d.ts +4 -0
  228. package/dist/seed/blog-posts-seed-data.js +7 -1
  229. package/dist/seed/events-seed-data.d.ts +5 -1
  230. package/dist/seed/events-seed-data.js +7 -1
  231. package/dist/seed/faq-seed-data.d.ts +16 -0
  232. package/dist/seed/faq-seed-data.js +22 -1
  233. package/dist/seed/products-art-seed-data.js +49 -7
  234. package/dist/seed/products-auctions-seed-data.js +12 -4
  235. package/dist/seed/products-classifieds-seed-data.d.ts +5 -0
  236. package/dist/seed/products-classifieds-seed-data.js +65 -9
  237. package/dist/seed/products-digital-codes-seed-data.d.ts +5 -0
  238. package/dist/seed/products-digital-codes-seed-data.js +65 -9
  239. package/dist/seed/products-live-items-seed-data.d.ts +5 -0
  240. package/dist/seed/products-live-items-seed-data.js +33 -5
  241. package/dist/seed/products-preorders-seed-data.d.ts +1 -1
  242. package/dist/seed/products-preorders-seed-data.js +6 -2
  243. package/dist/seed/products-prize-draws-seed-data.d.ts +5 -0
  244. package/dist/seed/products-prize-draws-seed-data.js +57 -8
  245. package/dist/seed/products-standard-seed-data.js +6 -2
  246. package/dist/seed/products-stickers-seed-data.js +49 -7
  247. package/dist/seed/reviews-seed-data.js +2 -1
  248. package/dist/seed/site-settings-seed-data.js +5 -3
  249. package/dist/seed/stores-seed-data.d.ts +4 -0
  250. package/dist/seed/stores-seed-data.js +7 -1
  251. package/dist/seo/json-ld.d.ts +44 -2
  252. package/dist/seo/json-ld.js +57 -11
  253. package/dist/styles.css +368 -90
  254. package/dist/tailwind-utilities.css +1 -1
  255. package/dist/tokens/index.d.ts +1 -1
  256. package/dist/tokens/index.js +1 -1
  257. package/dist/tokens/themes/default-dark.js +6 -0
  258. package/dist/tokens/themes/default-light.js +5 -0
  259. package/dist/tokens/tokens.css +84 -0
  260. package/dist/ui/DataTable.js +1 -1
  261. package/dist/ui/DataTable.style.css +3 -3
  262. package/dist/ui/components/BulkActionBar.style.css +1 -3
  263. package/dist/ui/components/Button.js +15 -6
  264. package/dist/ui/components/Dropdown.style.css +1 -4
  265. package/dist/ui/components/FilterChipGroup.js +1 -1
  266. package/dist/ui/components/FilterDrawer.style.css +1 -1
  267. package/dist/ui/components/Icon.d.ts +53 -0
  268. package/dist/ui/components/Icon.js +41 -0
  269. package/dist/ui/components/IconButton.d.ts +13 -0
  270. package/dist/ui/components/IconButton.js +13 -0
  271. package/dist/ui/components/IconButton.style.css +18 -10
  272. package/dist/ui/components/ListingLayout.style.css +2 -10
  273. package/dist/ui/components/ListingToolbar.js +6 -6
  274. package/dist/ui/components/Modal.js +1 -1
  275. package/dist/ui/components/PaginatedSelect.style.css +3 -3
  276. package/dist/ui/components/Pagination.style.css +1 -6
  277. package/dist/ui/components/RichTextEditor.js +1 -1
  278. package/dist/ui/components/RowActionMenu.style.css +1 -1
  279. package/dist/ui/components/SideDrawer.style.css +1 -1
  280. package/dist/ui/components/Slider.js +1 -1
  281. package/dist/ui/components/TabBarShell.d.ts +78 -0
  282. package/dist/ui/components/TabBarShell.js +95 -0
  283. package/dist/ui/components/Tabs.js +12 -8
  284. package/dist/ui/components/Tabs.style.css +249 -41
  285. package/dist/ui/components/ViewToggle.style.css +1 -5
  286. package/dist/ui/components/tabs-overflow.d.ts +17 -0
  287. package/dist/ui/components/tabs-overflow.js +142 -0
  288. package/dist/ui/icons/icon-registry.d.ts +38 -0
  289. package/dist/ui/icons/icon-registry.js +37 -0
  290. package/dist/ui/index.d.ts +6 -0
  291. package/dist/ui/index.js +3 -0
  292. package/dist/utils/index.d.ts +1 -1
  293. package/dist/utils/index.js +1 -1
  294. package/dist/utils/media-url.d.ts +27 -4
  295. package/dist/utils/media-url.js +37 -6
  296. package/dist/utils/search-txt.d.ts +71 -0
  297. package/dist/utils/search-txt.js +118 -0
  298. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { useRouter } from "next/navigation";
5
- import { Button, Stack, LoginRequiredModal } from "../../../ui";
5
+ import { Button, Stack, LoginRequiredModal, Icon } from "../../../ui";
6
6
  import { useToast } from "../../../ui/components/Toast";
7
7
  import { useAddToCart } from "../../cart/hooks/useAddToCart";
8
8
  import { apiClient } from "../../../http";
@@ -22,9 +22,13 @@ import { normalizeError } from "../../../errors/normalize";
22
22
  export function ProductDetailActions({ productId, productTitle, productImage, price, currency, storeId, storeName, inStock, variant = "desktop", }) {
23
23
  const router = useRouter();
24
24
  const { showToast } = useToast();
25
- const { requireAuth, modalOpen, modalMessage, closeModal } = useAuthGate();
25
+ const { requireAuth, isAuthResolving, modalOpen, modalMessage, closeModal } = useAuthGate();
26
26
  const [busy, setBusy] = useState(null);
27
27
  const [wishlisted, setWishlisted] = useState(false);
28
+ // `isAuthResolving` covers the window where the session has not settled and a
29
+ // click would be parked by useAuthGate rather than acted on. Without it the
30
+ // button looks live, swallows the click, and appears to do nothing.
31
+ const blocked = busy !== null || isAuthResolving;
28
32
  // Success toast (item + updated count/total) is now shown by useAddToCart
29
33
  // itself — centralised so every add-to-cart call site gets the same
30
34
  // richer toast, not just this one.
@@ -98,24 +102,24 @@ export function ProductDetailActions({ productId, productTitle, productImage, pr
98
102
  actions: [
99
103
  {
100
104
  id: ACTION_ID.ADD_TO_WISHLIST,
101
- label: wishlisted ? "♥ Saved" : "♡ Wishlist",
105
+ label: wishlisted ? "Saved" : "Wishlist",
102
106
  variant: "ghost",
103
107
  grow: false,
104
- disabled: busy !== null || wishlisted,
108
+ disabled: blocked || wishlisted,
105
109
  onClick: handleWishlist,
106
110
  },
107
111
  {
108
112
  id: ACTION_ID.ADD_TO_CART,
109
113
  label: busy === "cart" ? "Adding…" : "Add to Cart",
110
114
  variant: "outline",
111
- disabled: !inStock || busy !== null,
115
+ disabled: !inStock || blocked,
112
116
  onClick: () => handleAddToCart(),
113
117
  },
114
118
  {
115
119
  id: ACTION_ID.BUY_NOW,
116
120
  label: !inStock ? "Out of Stock" : busy === "buy" ? "Loading…" : "Buy Now",
117
121
  variant: "primary",
118
- disabled: !inStock || busy !== null,
122
+ disabled: !inStock || blocked,
119
123
  onClick: handleBuyNow,
120
124
  },
121
125
  ],
@@ -132,5 +136,5 @@ export function ProductDetailActions({ productId, productTitle, productImage, pr
132
136
  if (variant === "mobile") {
133
137
  return _jsx(_Fragment, { children: authModal });
134
138
  }
135
- return (_jsxs(_Fragment, { children: [authModal, _jsxs(Stack, { gap: "sm", children: [_jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: !inStock || busy !== null, onClick: handleBuyNow, children: !inStock ? "Out of Stock" : busy === "buy" ? "Loading…" : "Buy Now" }), _jsx(Button, { variant: "secondary", size: "md", className: "w-full", disabled: !inStock || busy !== null, onClick: () => handleAddToCart(), children: !inStock ? "Out of Stock" : busy === "cart" ? "Adding…" : "Add to Cart" }), _jsx(Button, { variant: "ghost", size: "md", className: "w-full", disabled: busy !== null || wishlisted, onClick: handleWishlist, children: wishlisted ? "♥ In Wishlist" : "♡ Add to Wishlist" })] })] }));
139
+ return (_jsxs(_Fragment, { children: [authModal, _jsxs(Stack, { gap: "sm", children: [_jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: !inStock || blocked, onClick: handleBuyNow, children: !inStock ? "Out of Stock" : busy === "buy" ? "Loading…" : "Buy Now" }), _jsx(Button, { variant: "secondary", size: "md", className: "w-full", disabled: !inStock || blocked, onClick: () => handleAddToCart(), children: !inStock ? "Out of Stock" : busy === "cart" ? "Adding…" : "Add to Cart" }), _jsxs(Button, { variant: "ghost", size: "md", className: "w-full", disabled: blocked || wishlisted, onClick: handleWishlist, children: [_jsx(Icon, { name: "wishlist", size: "md", filled: wishlisted }), wishlisted ? "In Wishlist" : "Add to Wishlist"] })] })] }));
136
140
  }
@@ -22,7 +22,7 @@ const CLS_BUNDLE_LABEL = "text-[length:var(--appkit-text-xs)] text-success dark:
22
22
  const CLS_BUNDLE_CTA = "flex-shrink-0 rounded-lg bg-success-solid hover:brightness-110 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-success-on-solid transition-colors";
23
23
  import { ROUTES } from "../../../next";
24
24
  import { getDefaultCurrency } from "../../../core/baseline-resolver";
25
- import { Button, Container, Div, Heading, Main, Nav, PriceDisplay, RichText, Row, Section, Span, Stack, Text, Ul, Ol, Li, Dl, Dt, Dd, } from "../../../ui";
25
+ import { Button, Container, Div, Heading, Main, Nav, PriceDisplay, RichText, Row, Section, Span, Stack, Text, Ul, Ol, Li, Dl, Dt, Dd, Icon, } from "../../../ui";
26
26
  import { normalizeRichTextHtml } from "../../../utils/string.formatter";
27
27
  import { safeDisplayName } from "../../../security";
28
28
  import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel";
@@ -204,7 +204,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
204
204
  ? ` — only ${effectiveStock} left`
205
205
  : ""] })] })) : (_jsxs(Span, { layout: "inline-flex", gap: "xs", className: `w-fit .5 ${inStock ? CLS_STOCK_IN : CLS_STOCK_OUT}`, rounded: "full", padding: "pill-md", size: "xs", weight: "medium", children: [inStock ? "✓ In Stock" : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
206
206
  ? ` — only ${effectiveStock} left`
207
- : ""] })), (categorySlugs.length > 0 || subcategory || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [categorySlugs.map((slug, i) => (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(slug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:bg-primary-900/20 dark:hover:text-primary-400", children: categoryNames[i] ?? slug }, slug))), subcategory && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", transform: "capitalize", children: subcategory })), brand && brandSlug && (_jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:bg-primary-900/20 dark:hover:text-primary-400", children: brand })), brand && !brandSlug && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand }))] })), bundleMemberships.length > 0 && (_jsx(Row, { gap: "sm", wrap: true, children: bundleMemberships.map((b) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: CLS_BUNDLE_PILL, children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), "In bundle: ", b.title] }, b.id))) })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, codAvailable: codEnabled === true, codHref: String(ROUTES.PUBLIC.HOW_CHECKOUT_WORKS), emiAvailable: emiEnabled === true, emiHref: String(ROUTES.PUBLIC.HOW_EMI_WORKS), condition: condition ?? undefined, returnable: returnPolicy != null && returnPolicy.length > 0, labels: {
207
+ : ""] })), (categorySlugs.length > 0 || subcategory || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [categorySlugs.map((slug, i) => (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(slug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryNames[i] ?? slug }, slug))), subcategory && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", transform: "capitalize", children: subcategory })), brand && brandSlug && (_jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand })), brand && !brandSlug && (_jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand }))] })), bundleMemberships.length > 0 && (_jsx(Row, { gap: "sm", wrap: true, children: bundleMemberships.map((b) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: CLS_BUNDLE_PILL, children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), "In bundle: ", b.title] }, b.id))) })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, codAvailable: codEnabled === true, codHref: String(ROUTES.PUBLIC.HOW_CHECKOUT_WORKS), emiAvailable: emiEnabled === true, emiHref: String(ROUTES.PUBLIC.HOW_EMI_WORKS), condition: condition ?? undefined, returnable: returnPolicy != null && returnPolicy.length > 0, labels: {
208
208
  featured: "Featured",
209
209
  fasterDelivery: "Faster Delivery",
210
210
  ratedSeller: "Rated Seller",
@@ -232,7 +232,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
232
232
  storeName: typeof p.storeName === "string" ? p.storeName : undefined,
233
233
  inStock,
234
234
  variant: "desktop",
235
- })) : (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: true, title: ACTION_NOT_WIRED, children: inStock ? "Buy Now" : "Out of Stock" }), _jsx(Button, { variant: "secondary", size: "md", className: "w-full", disabled: true, title: ACTION_NOT_WIRED, children: inStock ? "Add to Cart" : "Out of Stock" }), _jsx(Button, { variant: "ghost", size: "md", className: "w-full", disabled: true, title: ACTION_NOT_WIRED, children: "\u2661 Add to Wishlist" })] })), allowOffers && offerableType && offersEnabled && price !== null && renderOfferAction?.({
235
+ })) : (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: true, title: ACTION_NOT_WIRED, children: inStock ? "Buy Now" : "Out of Stock" }), _jsx(Button, { variant: "secondary", size: "md", className: "w-full", disabled: true, title: ACTION_NOT_WIRED, children: inStock ? "Add to Cart" : "Out of Stock" }), _jsxs(Button, { variant: "ghost", size: "md", className: "w-full", disabled: true, title: ACTION_NOT_WIRED, children: [_jsx(Icon, { name: "wishlist", size: "md" }), "Add to Wishlist"] })] })), allowOffers && offerableType && offersEnabled && price !== null && renderOfferAction?.({
236
236
  productId: product.id,
237
237
  price,
238
238
  currency,
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment } from "react";
3
- import { Row, Span, TextLink } from "../../../ui";
3
+ import { Row, Span, TextLink, Icon } from "../../../ui";
4
4
  export function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, condition, returnable, freeShipping, wishlistCount, categoryProductCount, categoryName, codAvailable, codHref, emiAvailable, emiHref, labels, formatCount, categoryBadgeClassName, }) {
5
5
  const badges = [];
6
6
  if (featured) {
@@ -89,7 +89,7 @@ export function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, co
89
89
  if (wishlistCount && wishlistCount > 0) {
90
90
  badges.push({
91
91
  key: "wishlist",
92
- icon: _jsx(Span, { size: "xs", children: "\u2665" }),
92
+ icon: _jsx(Icon, { name: "wishlist", size: "xs", filled: true }),
93
93
  label: labels.wishlistCount(wishlistCount),
94
94
  colorClass: "text-pink-700 dark:text-pink-300",
95
95
  bgClass: "bg-pink-50 dark:bg-pink-900/30 border-pink-200 dark:border-pink-800",
@@ -11,7 +11,7 @@ const SECTION_LABEL_CLASS = "text-[var(--appkit-font-size-2xs,11px)] font-semibo
11
11
  const ROW_BASE_CLASS = "flex items-start gap-[var(--appkit-space-2)] rounded-lg border px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-xs)] cursor-pointer transition-colors";
12
12
  const ROW_CHECKED_CLASS = "border-primary bg-primary/5 dark:bg-primary/10";
13
13
  const ROW_DISABLED_CLASS = "border-[var(--appkit-color-border)] opacity-50 cursor-not-allowed";
14
- const ROW_DEFAULT_CLASS = "border-[var(--appkit-color-border)] hover:bg-zinc-50 hover:bg-[var(--appkit-color-surface-elevated)]";
14
+ const ROW_DEFAULT_CLASS = "border-[var(--appkit-color-border)] hover:bg-surface-hover";
15
15
  const OVER_LIMIT_BANNER_CLASS = "rounded-lg border border-error/20 bg-error-surface px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-xs)] text-error";
16
16
  const EMPTY_STATE_CLASS = "rounded-lg border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-3)] py-[var(--appkit-space-4)] text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] text-left";
17
17
  function applyProductType(list, productType) {
@@ -75,12 +75,21 @@ export function ProductFilters({ table, categoryOptions = [], brandOptions = [],
75
75
  { value: PRODUCT_FIELDS.CONDITION_VALUES.REFURBISHED, label: t("conditionRefurbished") },
76
76
  { value: PRODUCT_FIELDS.CONDITION_VALUES.BROKEN, label: t("conditionBroken") },
77
77
  ];
78
- const defaultStatusOptions = [
79
- { value: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED, label: t("statusPublished") },
80
- { value: PRODUCT_FIELDS.STATUS_VALUES.DRAFT, label: t("statusDraft") },
81
- { value: PRODUCT_FIELDS.STATUS_VALUES.IN_REVIEW, label: t("statusInReview") },
82
- { value: PRODUCT_FIELDS.STATUS_VALUES.ARCHIVED, label: t("statusArchived") },
83
- ];
78
+ const resolvedVariant = variant ?? (showStatus ? "admin" : "public");
79
+ const shouldShowStatus = resolvedVariant !== "public" || showStatus;
80
+ // Built ONLY when the status facet actually renders. This used to be
81
+ // constructed unconditionally at the top of the component, so every public
82
+ // listing that never shows a status facet still called t() four times —
83
+ // five such listings mount ProductFilters, which is how one missing key
84
+ // (`statusInReview`) produced a MISSING_MESSAGE storm on every page load.
85
+ const defaultStatusOptions = shouldShowStatus
86
+ ? [
87
+ { value: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED, label: t("statusPublished") },
88
+ { value: PRODUCT_FIELDS.STATUS_VALUES.DRAFT, label: t("statusDraft") },
89
+ { value: PRODUCT_FIELDS.STATUS_VALUES.IN_REVIEW, label: t("statusInReview") },
90
+ { value: PRODUCT_FIELDS.STATUS_VALUES.ARCHIVED, label: t("statusArchived") },
91
+ ]
92
+ : [];
84
93
  const splitPipe = (key) => table.get(key) ? table.get(key).split("|").filter(Boolean) : [];
85
94
  const selectedCategories = splitPipe(TABLE_KEYS.CATEGORY);
86
95
  const selectedBrands = splitPipe(TABLE_KEYS.BRAND);
@@ -90,7 +99,5 @@ export function ProductFilters({ table, categoryOptions = [], brandOptions = [],
90
99
  const selectedStatuses = splitPipe(TABLE_KEYS.STATUS);
91
100
  const selectedSublistings = splitPipe(TABLE_KEYS.SUBLISTING_CATEGORY);
92
101
  const selectedFeatures = splitPipe(TABLE_KEYS.FEATURES);
93
- const resolvedVariant = variant ?? (showStatus ? "admin" : "public");
94
- const shouldShowStatus = resolvedVariant !== "public" || showStatus;
95
102
  return (_jsxs(Div, { children: [loadCategoryOptions ? (_jsx(AsyncFacetSection, { title: t("category"), loadOptions: loadCategoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), defaultCollapsed: false })) : categoryOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("category"), options: categoryOptions, selected: selectedCategories, onChange: (vals) => table.set(TABLE_KEYS.CATEGORY, vals.join("|")), searchable: true, defaultCollapsed: categoryOptions.length > 6 })) : null, _jsx(FilterFacetSection, { title: t("condition"), options: conditionOptions, selected: selectedConditions, onChange: (vals) => table.set(TABLE_KEYS.CONDITION, vals.join("|")), searchable: false, defaultCollapsed: false }), _jsx(RangeFilter, { title: t("priceRange"), minValue: table.get(TABLE_KEYS.MIN_PRICE), maxValue: table.get(TABLE_KEYS.MAX_PRICE), onMinChange: (v) => table.set(TABLE_KEYS.MIN_PRICE, v), onMaxChange: (v) => table.set(TABLE_KEYS.MAX_PRICE, v), prefix: currencyPrefix, showSlider: true, minBound: 0, maxBound: 500000, step: 500, minPlaceholder: t("minPrice"), maxPlaceholder: t("maxPrice"), defaultCollapsed: false }), loadBrandOptions ? (_jsx(AsyncFacetSection, { title: t("brand"), loadOptions: loadBrandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), defaultCollapsed: false })) : brandOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("brand"), options: brandOptions, selected: selectedBrands, onChange: (vals) => table.set(TABLE_KEYS.BRAND, vals.join("|")), searchable: brandOptions.length > 4, defaultCollapsed: brandOptions.length > 6 })) : null, loadStoreOptions ? (_jsx(AsyncFacetSection, { title: t("store"), loadOptions: loadStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), defaultCollapsed: false })) : resolvedStoreOptions.length > 0 ? (_jsx(FilterFacetSection, { title: t("store"), options: resolvedStoreOptions, selected: selectedSellers, onChange: (vals) => table.set(TABLE_KEYS.STORE_ID, vals.join("|")), searchable: resolvedStoreOptions.length > 4, defaultCollapsed: resolvedStoreOptions.length > 6 })) : null, showShipping && (_jsx(SwitchFilter, { title: t("shipping"), label: t("freeShippingOnly"), checked: table.get(TABLE_KEYS.FREE_SHIPPING) === "true", onChange: (v) => table.set(TABLE_KEYS.FREE_SHIPPING, v ? "true" : ""), defaultCollapsed: false })), tagOptions.length > 0 && (_jsx(FilterFacetSection, { title: t("tags"), options: tagOptions, selected: selectedTags, onChange: (vals) => table.set(TABLE_KEYS.TAGS, vals.join("|")), searchable: tagOptions.length > 4, defaultCollapsed: tagOptions.length > 6 })), shouldShowStatus && (_jsx(FilterFacetSection, { title: t("status"), options: statusOptions ?? defaultStatusOptions, selected: selectedStatuses, onChange: (vals) => table.set(TABLE_KEYS.STATUS, vals.join("|")), searchable: false, defaultCollapsed: false })), sublistingCategoryOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Sublisting Type", options: sublistingCategoryOptions, selected: selectedSublistings, onChange: (vals) => table.set(TABLE_KEYS.SUBLISTING_CATEGORY, vals.join("|")), searchable: sublistingCategoryOptions.length > 4, defaultCollapsed: false })), showBundleFilter && (_jsx(SwitchFilter, { title: "Bundles", label: "Part of a bundle only", checked: table.get(TABLE_KEYS.IS_PART_OF_BUNDLE) === "true", onChange: (v) => table.set(TABLE_KEYS.IS_PART_OF_BUNDLE, v ? "true" : ""), defaultCollapsed: false })), featureOptions.length > 0 && (_jsx(FilterFacetSection, { title: "Features", options: featureOptions, selected: selectedFeatures, onChange: (vals) => table.set(TABLE_KEYS.FEATURES, vals.join("|")), searchable: featureOptions.length > 4, defaultCollapsed: featureOptions.length > 4 }))] }));
96
103
  }
@@ -15,6 +15,7 @@ import { useProductFeatures } from "./ProductFeaturesContext";
15
15
  import { PRODUCT_FEATURE_CARD_MAX_VISIBLE } from "../constants/product-features.constants";
16
16
  import { normalizeListingType } from "../utils/listing-type";
17
17
  import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
18
+ import { WishlistHeartButton } from "../../wishlist/components/WishlistHeartButton";
18
19
  const __P = {
19
20
  p3: "p-[var(--appkit-space-3)]",
20
21
  };
@@ -30,15 +31,11 @@ const CLS_BADGE_LIMITED = "rounded-full bg-info-solid px-[var(--appkit-space-2)]
30
31
  // inverts with the theme. `warning` because success/info/error are each already
31
32
  // taken by a sibling pill in this same stack.
32
33
  const CLS_BADGE_OFFERABLE = "rounded-full bg-warning-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[10px] font-bold text-warning-on-solid shadow-sm";
33
- const CLS_HEART_ACTIVE = "bg-[var(--appkit-color-surface-elevated)]/90 text-error hover:bg-[var(--appkit-color-surface-elevated)]";
34
- const CLS_HEART_IDLE = "bg-[var(--appkit-color-surface-elevated)]/90 text-[var(--appkit-color-text-muted)] hover:text-error hover:bg-[var(--appkit-color-surface-elevated)]";
35
34
  const CLS_STAR = "text-[11px] text-warning";
36
35
  const CLS_DISCOUNT_TEXT = "mt-1 text-[11px] font-semibold text-error";
37
36
  const CLS_DISCOUNT_TEXT_BARE = "text-[10px] text-error";
38
37
  const CLS_STAR_BARE = "text-warning";
39
38
  const CLS_BID_TEXT = "text-[11px] text-error";
40
- const CLS_HEART_ROSE_ACTIVE = "text-error";
41
- const CLS_HEART_ROSE_IDLE = "text-zinc-300 dark:text-[var(--appkit-color-text-muted)] hover:text-error";
42
39
  export function ProductCard({ product, href, onClick, onAddToWishlist, isWishlisted, onAddToCart, onBuyNow, className = "", selectionMode = false, isSelected = false, onSelect, }) {
43
40
  const discount = product.originalPrice && product.originalPrice > product.price
44
41
  ? Math.round(((product.originalPrice - product.price) / product.originalPrice) *
@@ -55,9 +52,9 @@ export function ProductCard({ product, href, onClick, onAddToWishlist, isWishlis
55
52
  ? (e) => { e.preventDefault(); e.stopPropagation(); onSelect?.(product.id); }
56
53
  : onClick && !href ? () => onClick(product) : undefined;
57
54
  const cardBody = (_jsxs(Div, { role: selectionMode ? "checkbox" : onClick ? "button" : undefined, "aria-checked": selectionMode ? isSelected : undefined, tabIndex: selectionMode || (onClick && !href) ? 0 : undefined, onKeyDown: selectionMode
58
- ? (e) => (e.key === "Enter" || e.key === " ") && onSelect?.(product.id)
55
+ ? (e) => (e.key === "Enter" || e.key === "") && onSelect?.(product.id)
59
56
  : onClick && !href
60
- ? (e) => (e.key === "Enter" || e.key === " ") && onClick(product)
57
+ ? (e) => (e.key === "Enter" || e.key === "") && onClick(product)
61
58
  : undefined, onClick: handleCardClick, ...(!selectionMode ? longPress : {}), className: [
62
59
  "group relative flex h-full flex-col overflow-hidden",
63
60
  "rounded-2xl border bg-[var(--appkit-color-surface)]",
@@ -73,17 +70,11 @@ export function ProductCard({ product, href, onClick, onAddToWishlist, isWishlis
73
70
  .filter(Boolean)
74
71
  .join(" "), children: [_jsxs(Div, { className: `relative ${__O.hidden} aspect-square`, surface: "subtle", children: [product.mainImage ? (_jsx(MediaImage, { src: product.mainImage, alt: product.title, size: "card", className: "transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Row, { surface: "muted", className: "h-full w-full", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-30", size: "4xl", children: "\uD83D\uDECD\uFE0F" }) })), onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(product.id); }, label: isSelected ? "Deselect" : "Select", position: "top-2 left-2", className: selectionMode || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsx(Stack, { className: `absolute top-2 ${onSelect ? "left-10" : "left-2"}`, gap: "xs", children: !selectionMode && !isSelected && (_jsxs(_Fragment, { children: [discount && (_jsxs(Span, { weight: "bold", className: CLS_BADGE_NEW, children: ["-", discount, "%"] })), typeBadge && (_jsx(Span, { weight: "bold", className: `text-[10px] ${typeBadge.cls}`, rounded: "full", padding: "pill-xs", shadow: "sm", children: typeBadge.label })), product.partOfBundleIds && product.partOfBundleIds.length > 0 && (_jsx(Span, { className: CLS_BADGE_SALE, title: product.partOfBundleTitles && product.partOfBundleTitles.length > 0
75
72
  ? `In bundle: ${product.partOfBundleTitles[0]}`
76
- : "In a bundle", children: "Bundled" })), product.groupId && (_jsx(Span, { className: CLS_BADGE_TRENDING, title: product.groupTitle ? `Part of set: ${product.groupTitle}` : "Part of a set", children: product.isGroupParent ? "Set Parent" : "In Set" })), product.allowOffers && (_jsx(Span, { className: CLS_BADGE_OFFERABLE, title: "The seller accepts offers on this listing", children: "Taking Offers" })), product.sublistingCategoryId && (_jsx(Span, { className: CLS_BADGE_LIMITED, title: "Has variants or sub-listings", children: "Has Variants" }))] })) }), _jsx(BaseListingCard.IconOverlay, { groupIcon: product.groupIcon, sublistingIcon: product.sublistingIcon, position: "bottom-2 right-2" }), onAddToWishlist && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: (e) => {
73
+ : "In a bundle", children: "Bundled" })), product.groupId && (_jsx(Span, { className: CLS_BADGE_TRENDING, title: product.groupTitle ? `Part of set: ${product.groupTitle}` : "Part of a set", children: product.isGroupParent ? "Set Parent" : "In Set" })), product.allowOffers && (_jsx(Span, { className: CLS_BADGE_OFFERABLE, title: "The seller accepts offers on this listing", children: "Taking Offers" })), product.sublistingCategoryId && (_jsx(Span, { className: CLS_BADGE_LIMITED, title: "Has variants or sub-listings", children: "Has Variants" }))] })) }), _jsx(BaseListingCard.IconOverlay, { groupIcon: product.groupIcon, sublistingIcon: product.sublistingIcon, position: "bottom-2 right-2" }), onAddToWishlist && (_jsx(WishlistHeartButton, { size: "card", variant: "overlay", inWishlist: !!isWishlisted, addLabel: "Add to wishlist", removeLabel: "Remove from wishlist", className: "absolute right-2 top-2", onToggle: (e) => {
77
74
  e.stopPropagation();
78
75
  e.preventDefault();
79
76
  onAddToWishlist(product.id);
80
- }, "aria-label": isWishlisted ? "Remove from wishlist" : "Add to wishlist", className: [
81
- "absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full shadow-sm",
82
- "transition-all duration-150",
83
- isWishlisted
84
- ? CLS_HEART_ACTIVE
85
- : CLS_HEART_IDLE,
86
- ].join(" "), children: _jsx("svg", { className: "h-4 w-4", fill: isWishlisted ? "currentColor" : "none", stroke: "currentColor", strokeWidth: 2, viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" }) }) }))] }), _jsxs(Stack, { className: `flex-1 ${__P.p3} pt-[0.625rem]`, children: [_jsx(Text, { className: `leading-snug`, truncate: 2, size: "sm", weight: "semibold", color: "primary", children: product.title }), (() => {
77
+ } }))] }), _jsxs(Stack, { className: `flex-1 ${__P.p3} pt-[0.625rem]`, children: [_jsx(Text, { className: `leading-snug`, truncate: 2, size: "sm", weight: "semibold", color: "primary", children: product.title }), (() => {
87
78
  const categories = Array.isArray(product.categoryNames)
88
79
  ? product.categoryNames.filter(Boolean)
89
80
  : product.categoryName
@@ -142,7 +133,7 @@ function ProductListRow({ product, onClick, onAddToWishlist, isWishlisted, }) {
142
133
  100)
143
134
  : null;
144
135
  return (_jsxs(Div, { role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, onKeyDown: onClick
145
- ? (e) => (e.key === "Enter" || e.key === " ") && onClick(product)
136
+ ? (e) => (e.key === "Enter" || e.key === "") && onClick(product)
146
137
  : undefined, onClick: onClick ? () => onClick(product) : undefined, className: [
147
138
  "flex items-center gap-[var(--appkit-space-3)] p-[var(--appkit-space-2)] sm:p-[var(--appkit-space-3)]",
148
139
  "hover:bg-[var(--appkit-color-surface)]/60 transition-colors",
@@ -160,10 +151,10 @@ function ProductListRow({ product, onClick, onAddToWishlist, isWishlisted, }) {
160
151
  return (_jsx(Span, { weight: "medium", className: `text-[11px] ${low
161
152
  ? "text-warning"
162
153
  : "text-[var(--appkit-color-text-faint)]"}`, children: low ? `Only ${stock} left` : `${stock} in stock` }));
163
- })()] })] }), onAddToWishlist && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: (e) => {
154
+ })()] })] }), onAddToWishlist && (_jsx(WishlistHeartButton, { size: "inline", inWishlist: !!isWishlisted, addLabel: "Add to wishlist", removeLabel: "Remove from wishlist", onToggle: (e) => {
164
155
  e.stopPropagation();
165
156
  onAddToWishlist(product.id);
166
- }, "aria-label": isWishlisted ? "Remove from wishlist" : "Add to wishlist", className: `flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-full text-[length:var(--appkit-text-base)] leading-none ${isWishlisted ? CLS_HEART_ROSE_ACTIVE : CLS_HEART_ROSE_IDLE}`, children: isWishlisted ? "♥" : "♡" }))] }));
157
+ } }))] }));
167
158
  }
168
159
  function safeHref(href) {
169
160
  if (!href)
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Button, Row } from "@mohasinac/appkit/ui";
3
+ import { Tabs, TabsList, TabsTrigger } from "@mohasinac/appkit/ui";
4
4
  import React from "react";
5
5
  import { Div } from "../../../ui/components/Div";
6
6
  const DEFAULT_TABS = [
@@ -19,7 +19,10 @@ export function ProductTabs({ renderDescription, renderSpecs, renderReviews, ext
19
19
  ...extraTabs,
20
20
  ];
21
21
  const tabs = allTabs.length > 0 ? allTabs : DEFAULT_TABS;
22
- return (_jsxs(Div, { className: className, children: [renderTabBar ? (renderTabBar(activeTab, setActiveTab, tabs)) : (_jsx(Row, { gap: "md", className: "border-b mb-4", children: tabs.map((t) => (_jsx(Button, { variant: "ghost", type: "button", onClick: () => setActiveTab(t.value), paddingX: "none", paddingY: "xs", textSize: "sm", weight: "medium", rounded: "none", className: `border-b-2 transition-colors ${activeTab === t.value
23
- ? "border-current text-current"
24
- : "border-transparent text-[var(--appkit-color-text-muted)]"}`, children: t.label }, t.value))) })), activeTab === "description" && renderDescription?.(), activeTab === "specs" && renderSpecs?.(), activeTab === "reviews" && renderReviews?.(), renderExtraTab?.(activeTab)] }));
22
+ return (_jsxs(Div, { className: className, children: [renderTabBar ? (renderTabBar(activeTab, setActiveTab, tabs)) : (
23
+ // Was a hand-rolled <Row> of ghost <Button>s whose ACTIVE state was
24
+ // `border-current text-current` — the inherited colour, i.e. visually
25
+ // identical to inactive. It also had no role="tablist"/role="tab"/
26
+ // aria-selected at all; routing through Tabs supplies all three.
27
+ _jsx(Tabs, { value: activeTab, onChange: setActiveTab, className: "mb-4", children: _jsx(TabsList, { children: tabs.map((t) => (_jsx(TabsTrigger, { value: t.value, children: t.label }, t.value))) }) })), activeTab === "description" && renderDescription?.(), activeTab === "specs" && renderSpecs?.(), activeTab === "reviews" && renderReviews?.(), renderExtraTab?.(activeTab)] }));
25
28
  }
@@ -1,11 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
- import { Button } from "../../../ui/components/Button";
5
4
  import { Div } from "../../../ui/components/Div";
6
- const __O = {
7
- xAuto: "overflow-x-auto",
8
- };
5
+ import { Tabs, TabsList, TabsTrigger } from "../../../ui/components/Tabs";
9
6
  const STATIC_TABS = [
10
7
  { id: "description", label: "Description" },
11
8
  { id: "specs", label: "Specifications" },
@@ -40,7 +37,5 @@ export function ProductTabsShell({ descriptionContent, specsContent, gradingCont
40
37
  if (allTabs.length === 0)
41
38
  return null;
42
39
  const activeContent = allTabs.find((t) => t.id === activeId)?.content;
43
- return (_jsxs(Div, { className: `mt-8 ${className}`, children: [_jsx(Div, { layout: "flex", gap: "1", border: "default", className: `mb-6 ${__O.xAuto} border-b pb-px`, children: allTabs.map((t) => (_jsx(Button, { variant: "ghost", type: "button", onClick: () => setActiveId(t.id), paddingX: "md", paddingY: "none", textSize: "sm", weight: "medium", rounded: "none", className: `flex-shrink-0 -mb-px pb-[var(--appkit-space-3)] border-b-2 transition-colors ${activeId === t.id
44
- ? "border-[var(--appkit-color-primary)] text-[var(--appkit-color-primary)]"
45
- : "border-transparent text-[var(--appkit-color-text-muted)] hover:text-[var(--appkit-color-text-muted)]"}`, children: t.label }, t.id))) }), _jsx("div", { children: activeContent })] }));
40
+ return (_jsxs(Div, { className: `mt-8 ${className}`, children: [_jsx(Tabs, { value: activeId, onChange: setActiveId, className: "mb-6", children: _jsx(TabsList, { children: allTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, children: t.label }, t.id))) }) }), _jsx("div", { children: activeContent })] }));
46
41
  }
@@ -32,6 +32,8 @@ export declare const ACTION_ID: {
32
32
  readonly CANCEL_RESERVATION: "cancel-reservation";
33
33
  readonly CHECKOUT: "checkout";
34
34
  readonly CONTINUE_TO_VERIFY: "continue-to-verification";
35
+ readonly CONTINUE_TO_PAYMENT: "continue-to-payment";
36
+ readonly CHECKOUT_BACK: "checkout-back";
35
37
  readonly VERIFY_OTP: "verify-otp";
36
38
  readonly PAY_ONLINE: "pay-online";
37
39
  readonly PAY_COD: "pay-cod";
@@ -74,10 +76,25 @@ export interface ActionMeta {
74
76
  /** Message shown in LoginRequiredModal. Required when requiresAuth=true. */
75
77
  authMessage?: string;
76
78
  /**
77
- * RBAC permission key required to use this action.
78
- * If set, user must have this permission in addition to being signed in.
79
- * Uses the 85+ permission keys from the RBAC system.
80
- * Example: "products.create", "orders.refund", "admin.users.suspend"
79
+ * RBAC permission key required to use this action, checked by `useAuthGate`
80
+ * against `SessionUser.permissions`.
81
+ *
82
+ * 🛑 Two preconditions, and BOTH must hold or the action is a guaranteed
83
+ * denial that looks like a working permission check:
84
+ *
85
+ * 1. The value must be a **colon**-separated key that actually exists in
86
+ * `DEFAULT_ROLES` (`"admin:blog:read"`), not dot-separated. Three entries
87
+ * here carried dot-form keys (`"seller.payouts.request"`,
88
+ * `"seller.reviews.respond"`, `"user.account.delete"`) that match no role;
89
+ * they were removed 2026-08-27. Same family as the 182 guaranteed-403
90
+ * `permission:` lines deleted 2026-08-19.
91
+ * 2. `SessionUser.permissions` must be populated for the acting user — today
92
+ * that happens only on the employee path
93
+ * (`_internal/server/features/auth/permissions.ts`). Neither
94
+ * `buildSessionUser` nor `fetchUserProfileFromServer` sets it, so for an
95
+ * ordinary buyer/seller this field denies unconditionally.
96
+ *
97
+ * Leave it unset unless you have verified both.
81
98
  */
82
99
  requiredPermission?: string;
83
100
  /**
@@ -53,6 +53,8 @@ export const ACTION_ID = {
53
53
  // ── Checkout / navigation CTAs ───────────────────────────────────────────
54
54
  CHECKOUT: "checkout",
55
55
  CONTINUE_TO_VERIFY: "continue-to-verification",
56
+ CONTINUE_TO_PAYMENT: "continue-to-payment",
57
+ CHECKOUT_BACK: "checkout-back",
56
58
  VERIFY_OTP: "verify-otp",
57
59
  PAY_ONLINE: "pay-online",
58
60
  PAY_COD: "pay-cod",
@@ -98,6 +100,8 @@ export const ACTION_META = {
98
100
  // ── Checkout / navigation CTAs ────────────────────────────────────────────
99
101
  [ACTION_ID.CHECKOUT]: { id: ACTION_ID.CHECKOUT, label: regLabel("CART", "checkout", "Checkout"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
100
102
  [ACTION_ID.CONTINUE_TO_VERIFY]: { id: ACTION_ID.CONTINUE_TO_VERIFY, label: regLabel("CHECKOUT", "continue-to-verification", "Continue"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
103
+ [ACTION_ID.CONTINUE_TO_PAYMENT]: { id: ACTION_ID.CONTINUE_TO_PAYMENT, label: regLabel("CHECKOUT", "continue-to-payment", "Continue to payment"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
104
+ [ACTION_ID.CHECKOUT_BACK]: { id: ACTION_ID.CHECKOUT_BACK, label: regLabel("CHECKOUT", "back", "Back"), variant: "ghost" },
101
105
  [ACTION_ID.VERIFY_OTP]: { id: ACTION_ID.VERIFY_OTP, label: regLabel("CHECKOUT", "verify-otp", "Verify & Continue"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
102
106
  [ACTION_ID.PAY_ONLINE]: { id: ACTION_ID.PAY_ONLINE, label: regLabel("CHECKOUT", "pay-online", "Pay Online"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
103
107
  [ACTION_ID.PAY_COD]: { id: ACTION_ID.PAY_COD, label: regLabel("CHECKOUT", "pay-cod", "Cash on Delivery"), variant: "secondary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
@@ -113,8 +117,8 @@ export const ACTION_META = {
113
117
  [ACTION_ID.MESSAGE_SELLER]: { id: ACTION_ID.MESSAGE_SELLER, label: "Message Seller", variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to message a seller." },
114
118
  // ── Seller CTAs ───────────────────────────────────────────────────────────
115
119
  [ACTION_ID.BECOME_SELLER]: { id: ACTION_ID.BECOME_SELLER, label: "Apply as Seller", variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to apply as a seller." },
116
- [ACTION_ID.REQUEST_PAYOUT]: { id: ACTION_ID.REQUEST_PAYOUT, label: regLabel("STORE", "request-payout", "Request Payout"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to request a payout.", requiredPermission: "seller.payouts.request" },
117
- [ACTION_ID.RESPOND_TO_REVIEW]: { id: ACTION_ID.RESPOND_TO_REVIEW, label: regLabel("STORE", "reply-review", "Respond to Review"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to respond to a review.", requiredPermission: "seller.reviews.respond" },
120
+ [ACTION_ID.REQUEST_PAYOUT]: { id: ACTION_ID.REQUEST_PAYOUT, label: regLabel("STORE", "request-payout", "Request Payout"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to request a payout." },
121
+ [ACTION_ID.RESPOND_TO_REVIEW]: { id: ACTION_ID.RESPOND_TO_REVIEW, label: regLabel("STORE", "reply-review", "Respond to Review"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to respond to a review." },
118
122
  // ── User account actions ──────────────────────────────────────────────────
119
123
  [ACTION_ID.CANCEL_ORDER]: { id: ACTION_ID.CANCEL_ORDER, label: regLabel("USER", "cancel-order", "Cancel Order"), variant: "danger", requiresAuth: true, authMessage: "You need to be signed in to cancel an order." },
120
124
  [ACTION_ID.REQUEST_RETURN]: { id: ACTION_ID.REQUEST_RETURN, label: regLabel("USER", "request-return", "Request Return"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to request a return." },
@@ -125,7 +129,7 @@ export const ACTION_META = {
125
129
  [ACTION_ID.EDIT_ADDRESS]: { id: ACTION_ID.EDIT_ADDRESS, label: "Edit Address", variant: "ghost", requiresAuth: true, authMessage: "You need to be signed in to edit an address." },
126
130
  [ACTION_ID.DELETE_ADDRESS]: { id: ACTION_ID.DELETE_ADDRESS, label: regLabel("USER", "delete-address", "Delete Address"), variant: "danger", requiresAuth: true, authMessage: "You need to be signed in to delete an address." },
127
131
  [ACTION_ID.CHANGE_PASSWORD]: { id: ACTION_ID.CHANGE_PASSWORD, label: regLabel("USER", "update-password", "Change Password"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to change your password." },
128
- [ACTION_ID.DELETE_ACCOUNT]: { id: ACTION_ID.DELETE_ACCOUNT, label: "Delete Account", variant: "danger", requiresAuth: true, authMessage: "You need to be signed in to delete your account.", requiredPermission: "user.account.delete" },
132
+ [ACTION_ID.DELETE_ACCOUNT]: { id: ACTION_ID.DELETE_ACCOUNT, label: "Delete Account", variant: "danger", requiresAuth: true, authMessage: "You need to be signed in to delete your account." },
129
133
  };
130
134
  // Detail page action groups — ordered top-to-bottom in the right-hand panel
131
135
  export const DETAIL_ACTIONS = {
@@ -1,4 +1,5 @@
1
- import { sieveAnd, sieveFilter, SIEVE_OP, sortBy } from "@mohasinac/appkit/client";
1
+ import { sieveAnd, sieveFilter, SIEVE_OP } from "../../../utils/sieve-builder";
2
+ import { sortBy } from "../../../constants/sort";
2
3
  import { useQuery } from "@tanstack/react-query";
3
4
  import { apiClient } from "../../../http";
4
5
  import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints";
@@ -277,7 +277,7 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
277
277
  canFilter: boolean;
278
278
  canSort: boolean;
279
279
  };
280
- searchTokens: {
280
+ searchTxt: {
281
281
  canFilter: boolean;
282
282
  canSort: boolean;
283
283
  };
@@ -4,7 +4,8 @@ import { DatabaseError } from "../../../errors";
4
4
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
5
5
  import { cacheManager } from "../../../core";
6
6
  import { serverLogger } from "../../../monitoring";
7
- import { generateUniqueId, slugify, buildSearchTokens, tokenizeQuery, generateBarcodeId } from "../../../utils";
7
+ import { generateUniqueId, slugify, generateBarcodeId } from "../../../utils";
8
+ import { buildSearchTxt, parseSearchTxtQuery } from "../../../utils/search-txt";
8
9
  import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas";
9
10
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
10
11
  /**
@@ -45,8 +46,21 @@ function buildListingKindClause(kind, inverted) {
45
46
  const op = inverted ? "!=" : "==";
46
47
  return `${PRODUCT_FIELDS.LISTING_TYPE}${op}${canonical}`;
47
48
  }
48
- function buildProductSearchTokens(p) {
49
- return buildSearchTokens(p.title, p.description, p.brand, p.brandSlug, p.categoryNames, p.tags, p.features, p.condition, p.card?.setName, p.card?.cardNumber, p.grading?.service, p.specifications?.map((s) => `${s.name} ${s.value}`));
49
+ function buildProductSearchTxt(p) {
50
+ return buildSearchTxt([
51
+ p.title,
52
+ p.description,
53
+ p.brand,
54
+ p.brandSlug,
55
+ p.categoryNames,
56
+ p.tags,
57
+ p.features,
58
+ p.condition,
59
+ p.card?.setName,
60
+ p.card?.cardNumber,
61
+ p.grading?.service,
62
+ p.specifications?.map((s) => `${s.name} ${s.value}`),
63
+ ]);
50
64
  }
51
65
  export class ProductRepository extends BaseRepository {
52
66
  constructor() {
@@ -97,7 +111,7 @@ export class ProductRepository extends BaseRepository {
97
111
  const merged = { ...current, ...data };
98
112
  const updated = await super.update(id, {
99
113
  ...data,
100
- searchTokens: buildProductSearchTokens(merged),
114
+ searchTxt: buildProductSearchTxt(merged),
101
115
  });
102
116
  this.cacheSet(updated);
103
117
  return updated;
@@ -121,7 +135,7 @@ export class ProductRepository extends BaseRepository {
121
135
  barcodeId,
122
136
  slug: id,
123
137
  availableQuantity: input.stockQuantity,
124
- searchTokens: buildProductSearchTokens(input),
138
+ searchTxt: buildProductSearchTxt(input),
125
139
  createdAt: new Date(),
126
140
  updatedAt: new Date(),
127
141
  };
@@ -359,17 +373,25 @@ export class ProductRepository extends BaseRepository {
359
373
  if (opts?.storeId) {
360
374
  baseQuery = baseQuery.where(PRODUCT_FIELDS.STORE_ID, "==", opts.storeId);
361
375
  }
362
- const searchTokens = tokenizeQuery(opts?.search ?? "");
363
- if (searchTokens.length > 0 && opts?.categoriesIn && opts.categoriesIn.length > 0) {
364
- // Firestore cannot combine two array operators in one query
376
+ // Longest term first — only ONE may become the array-contains clause.
377
+ const searchTxt = parseSearchTxtQuery(opts?.search ?? "");
378
+ if (searchTxt.length > 0) {
379
+ // Firestore permits a single array operator per query, so when a search
380
+ // and a category filter are both present the SEARCH takes the clause and
381
+ // the category is refined by the caller.
382
+ //
383
+ // Two shapes here have each been reverted once and are both guarded by
384
+ // scripts/audit-search-semantics.mjs:
385
+ //
386
+ // 1. This branch was an empty `if` body — it emitted NO clause, so a
387
+ // search combined with `categoriesIn` dropped BOTH filters and
388
+ // returned an unrelated superset, silently.
389
+ // 2. Multi-term used `array-contains-any`, which is OR — "red dranzer"
390
+ // matched anything with either word. One `array-contains` on the most
391
+ // selective term, remaining terms AND-refined against `searchTxt`.
392
+ baseQuery = baseQuery.where(PRODUCT_FIELDS.SEARCH_TXT, "array-contains", searchTxt[0]);
365
393
  }
366
- else if (searchTokens.length === 1) {
367
- baseQuery = baseQuery.where(PRODUCT_FIELDS.SEARCH_TOKENS, "array-contains", searchTokens[0]);
368
- }
369
- else if (searchTokens.length > 1) {
370
- baseQuery = baseQuery.where(PRODUCT_FIELDS.SEARCH_TOKENS, "array-contains-any", searchTokens);
371
- }
372
- if (searchTokens.length === 0 && opts?.categoriesIn && opts.categoriesIn.length > 0) {
394
+ if (searchTxt.length === 0 && opts?.categoriesIn && opts.categoriesIn.length > 0) {
373
395
  let categoriesIn = opts.categoriesIn;
374
396
  if (categoriesIn.length > ProductRepository.MAX_ARRAY_CONTAINS_ANY) {
375
397
  // Products carry their FULL ancestor chain in `categorySlugs`, so a
@@ -559,7 +581,7 @@ ProductRepository.SIEVE_FIELDS = {
559
581
  // field had no entry: `throwExceptions:false` means an unknown field is
560
582
  // silently skipped. The toggle did nothing at all until 2026-08-21.
561
583
  shippingPaidBy: { canFilter: true, canSort: false },
562
- searchTokens: { canFilter: true, canSort: false },
584
+ searchTxt: { canFilter: true, canSort: false },
563
585
  "classified.meetupArea": { canFilter: true, canSort: false },
564
586
  "classified.acceptsShipping": { canFilter: true, canSort: false },
565
587
  "classified.negotiable": { canFilter: true, canSort: false },
@@ -349,7 +349,8 @@ export interface ProductDocument extends BaseDocument {
349
349
  * Auto-generated as `LIR-{8-char-hex}` on product creation when not supplied.
350
350
  * Override by scanning a pre-printed sticker during the create/edit flow. */
351
351
  barcodeId?: string;
352
- searchTokens?: string[];
352
+ /** Normalized edge-n-gram search field. Replaced `searchTokens`. */
353
+ searchTxt?: string[];
353
354
  }
354
355
  /**
355
356
  * Single prize within a prize-draw listing. A draw can have between 3 and 16
@@ -381,7 +382,7 @@ export declare const ProductStatusValues: {
381
382
  readonly ARCHIVED: "archived";
382
383
  };
383
384
  export declare const PRODUCT_COLLECTION: "products";
384
- export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTokens", "createdAt", "barcodeId"];
385
+ export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId"];
385
386
  export declare const DEFAULT_PRODUCT_DATA: Partial<ProductDocument>;
386
387
  export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
387
388
  export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
@@ -21,7 +21,7 @@ export const PRODUCT_INDEXED_FIELDS = [
21
21
  "isPromoted",
22
22
  "isOnSale",
23
23
  "isSold",
24
- "searchTokens",
24
+ "searchTxt",
25
25
  "createdAt",
26
26
  "barcodeId",
27
27
  ];
@@ -27,7 +27,14 @@ export declare function updateReview(userId: string, reviewId: string, input: Up
27
27
  export declare function deleteReview(userId: string, reviewId: string): Promise<void>;
28
28
  export declare function adminUpdateReview(adminId: string, reviewId: string, input: UpdateReviewActionInput): Promise<ReviewDocument | null>;
29
29
  export declare function adminDeleteReview(adminId: string, reviewId: string): Promise<void>;
30
- export declare function voteReviewHelpful(reviewId: string, helpful: boolean): Promise<void>;
30
+ /**
31
+ * Record a helpful vote. `userId` is required — without it the endpoint has no
32
+ * idempotency key and a repeated POST inflates the count without limit.
33
+ */
34
+ export declare function voteReviewHelpful(reviewId: string, helpful: boolean, userId: string): Promise<{
35
+ counted: boolean;
36
+ helpfulCount: number;
37
+ }>;
31
38
  export declare function listReviewsByProduct(productId: string, page?: number, pageSize?: number): Promise<FirebaseSieveResult<ReviewDocument>>;
32
39
  export declare function listAdminReviews(params?: {
33
40
  filters?: string;
@@ -136,13 +136,24 @@ export async function adminDeleteReview(adminId, reviewId) {
136
136
  });
137
137
  await reviewRepository.delete(reviewId);
138
138
  }
139
- export async function voteReviewHelpful(reviewId, helpful) {
139
+ /**
140
+ * Record a helpful vote. `userId` is required — without it the endpoint has no
141
+ * idempotency key and a repeated POST inflates the count without limit.
142
+ */
143
+ export async function voteReviewHelpful(reviewId, helpful, userId) {
140
144
  if (!reviewId?.trim()) {
141
145
  throw new ValidationError(ERR_REVIEW_ID_REQUIRED);
142
146
  }
143
- if (helpful) {
144
- await reviewRepository.incrementHelpful(reviewId);
147
+ if (!userId?.trim()) {
148
+ throw new ValidationError("A signed-in user is required to vote.");
149
+ }
150
+ if (!helpful) {
151
+ // Only "helpful" is recorded today; an un-vote would need its own path so
152
+ // it cannot be used to decrement a review it never incremented.
153
+ const review = await reviewRepository.findById(reviewId);
154
+ return { counted: false, helpfulCount: review?.helpfulCount ?? 0 };
145
155
  }
156
+ return reviewRepository.voteHelpful(reviewId, userId);
146
157
  }
147
158
  export async function listReviewsByProduct(productId, page = 1, pageSize = 10) {
148
159
  const result = await reviewRepository.listForProduct(productId, {