@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
@@ -1,8 +1,23 @@
1
- export interface AuctionBottomActionsProps {
2
- currentBid: number;
3
- currency: string;
1
+ import { type PlaceBidFormClientProps } from "./PlaceBidFormClient";
2
+ export interface AuctionBottomActionsProps extends PlaceBidFormClientProps {
4
3
  bidCount: number;
5
- isEnded: boolean;
6
- auctionEndDate: Date | null;
7
4
  }
8
- export declare function AuctionBottomActions({ currentBid, currency, bidCount, isEnded, auctionEndDate, }: AuctionBottomActionsProps): null;
5
+ /**
6
+ * Mobile/after-scroll CTA bar for an auction.
7
+ *
8
+ * 🛑 It owns its own modal rather than pointing at one.
9
+ *
10
+ * Two defects lived here. (a) The single Place Bid action ran
11
+ * `getElementById("auction-bid-form")?.scrollIntoView(...)` — but that id is
12
+ * set inside `renderBidForm`, which `AuctionDetailView` wraps in
13
+ * `hidden lg:block`. On mobile the target is `display:none`, so the bar's only
14
+ * action did nothing on the only viewport the bar exists for. A DOM id is not
15
+ * a contract; the element it names can be hidden, moved or removed without any
16
+ * signal here. (b) There was no Buy Now action at all, even though
17
+ * `MOBILE_PRIMARY_ACTIONS.auction` has declared `[PLACE_BID, BUY_NOW_AUCTION]`
18
+ * for a long time — a declared CTA nothing rendered (Root Cause #8's shape).
19
+ *
20
+ * Both actions now open the real form, which is the only place the Buy Now
21
+ * button can report an error or route to `/checkout?lane=auction`.
22
+ */
23
+ export declare function AuctionBottomActions(props: AuctionBottomActionsProps): import("react").JSX.Element;
@@ -1,8 +1,13 @@
1
1
  "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useState } from "react";
2
4
  import { useBottomActions } from "../../layout/hooks/useBottomActions";
3
5
  import { formatCurrency } from "../../../utils/number.formatter";
4
6
  import { ACTION_ID, ACTION_META } from "../../products/constants/action-defs";
5
7
  import { useCountdown } from "../../../react";
8
+ import { Modal } from "../../../ui";
9
+ import { isBuyNowAvailable } from "../../../_internal/shared/features/auctions/config";
10
+ import { PlaceBidFormClient, } from "./PlaceBidFormClient";
6
11
  function formatCountdownLabel(remaining) {
7
12
  if (!remaining)
8
13
  return undefined;
@@ -13,25 +18,51 @@ function formatCountdownLabel(remaining) {
13
18
  return `Ends in ${hours}h ${minutes}m ${seconds}s`;
14
19
  return `Ends in ${minutes}m ${seconds}s`;
15
20
  }
16
- export function AuctionBottomActions({ currentBid, currency, bidCount, isEnded, auctionEndDate, }) {
17
- const placeBidMeta = ACTION_META[ACTION_ID.PLACE_BID];
21
+ /**
22
+ * Mobile/after-scroll CTA bar for an auction.
23
+ *
24
+ * 🛑 It owns its own modal rather than pointing at one.
25
+ *
26
+ * Two defects lived here. (a) The single Place Bid action ran
27
+ * `getElementById("auction-bid-form")?.scrollIntoView(...)` — but that id is
28
+ * set inside `renderBidForm`, which `AuctionDetailView` wraps in
29
+ * `hidden lg:block`. On mobile the target is `display:none`, so the bar's only
30
+ * action did nothing on the only viewport the bar exists for. A DOM id is not
31
+ * a contract; the element it names can be hidden, moved or removed without any
32
+ * signal here. (b) There was no Buy Now action at all, even though
33
+ * `MOBILE_PRIMARY_ACTIONS.auction` has declared `[PLACE_BID, BUY_NOW_AUCTION]`
34
+ * for a long time — a declared CTA nothing rendered (Root Cause #8's shape).
35
+ *
36
+ * Both actions now open the real form, which is the only place the Buy Now
37
+ * button can report an error or route to `/checkout?lane=auction`.
38
+ */
39
+ export function AuctionBottomActions(props) {
40
+ const { currentBid, currency, bidCount, isEnded, auctionEndDate, buyNowPrice } = props;
41
+ const [open, setOpen] = useState(false);
18
42
  const remaining = useCountdown(auctionEndDate ?? undefined);
43
+ const placeBidMeta = ACTION_META[ACTION_ID.PLACE_BID];
44
+ const buyNowMeta = ACTION_META[ACTION_ID.BUY_NOW_AUCTION];
45
+ // Same single predicate the form and the card use. `!!props.onBuyNow` matters
46
+ // because the preview/demo mount passes no action.
47
+ const buyNowAvailable = isBuyNowAvailable({ buyNowPrice, currentBid, isEnded }) && !!props.onBuyNow;
19
48
  useBottomActions(isEnded
20
49
  ? {}
21
50
  : {
22
51
  actions: [
23
- {
24
- ...placeBidMeta,
25
- onClick: () => {
26
- document
27
- .getElementById("auction-bid-form")
28
- ?.scrollIntoView({ behavior: "smooth" });
29
- },
30
- },
52
+ { ...placeBidMeta, onClick: () => setOpen(true) },
53
+ ...(buyNowAvailable && buyNowPrice !== null
54
+ ? [
55
+ {
56
+ ...buyNowMeta,
57
+ label: `Buy Now — ${formatCurrency(buyNowPrice, currency)}`,
58
+ onClick: () => setOpen(true),
59
+ },
60
+ ]
61
+ : []),
31
62
  ],
32
63
  secondaryLabel: formatCountdownLabel(remaining),
33
64
  infoLabel: `${formatCurrency(currentBid, currency)} · ${bidCount} bid${bidCount !== 1 ? "s" : ""}`,
34
65
  desktop: "after-scroll",
35
66
  });
36
- return null;
67
+ return (_jsx(Modal, { isOpen: open, onClose: () => setOpen(false), size: "md", title: "Place your bid", children: _jsx(PlaceBidFormClient, { ...props }) }));
37
68
  }
@@ -1,5 +1,5 @@
1
1
  import type { ProductFeatureDocument } from "../../products/schemas/product-features";
2
- import type { PlaceBidInput } from "./PlaceBidFormClient";
2
+ import type { PlaceBidInput, BidActionEnvelope, BuyNowSuccess } from "./PlaceBidFormClient";
3
3
  export interface AuctionDetailPageViewProps {
4
4
  id: string;
5
5
  /**
@@ -9,8 +9,13 @@ export interface AuctionDetailPageViewProps {
9
9
  * When absent, falls back to fetching by slug/id (backward compat).
10
10
  */
11
11
  initialAuction?: import("../../products/schemas/firestore").ProductDocument | null;
12
- onPlaceBid?: (input: PlaceBidInput) => Promise<unknown>;
13
- onBuyNow?: () => Promise<unknown>;
12
+ /**
13
+ * Typed against the single `ActionResult` envelope — see the note on
14
+ * `BidActionEnvelope`. `Promise<unknown>` here is what let a doubly-wrapped
15
+ * result flow through to a client that read the wrong `ok`.
16
+ */
17
+ onPlaceBid?: (input: PlaceBidInput) => Promise<BidActionEnvelope<unknown>>;
18
+ onBuyNow?: () => Promise<BidActionEnvelope<BuyNowSuccess>>;
14
19
  /** SSR-loaded productFeatures (platform + store-scope). See ProductDetailPageView for semantics. */
15
20
  productFeatures?: ProductFeatureDocument[];
16
21
  }
@@ -46,7 +46,7 @@ function toDescriptionHtml(raw) {
46
46
  }
47
47
  function renderAuctionInfoPanel(props) {
48
48
  const { productId, title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref } = props;
49
- return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: formatCurrency(buyNowPrice, currency) })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), 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: categoryName || category }), !category && categoryName && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: categoryName }), 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 })] })), productFeatures && features.length > 0 && _jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures }), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this item" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => _jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i)) })] })), descriptionHtml && _jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" }), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Listed by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && _jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" })] }) }))] }));
49
+ return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: formatCurrency(buyNowPrice, currency) }), _jsx(Span, { size: "xs", color: "faint", children: "\u2014 in \u201CPlace a bid\u201D" })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), 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: categoryName || category }), !category && categoryName && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: categoryName }), 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 })] })), productFeatures && features.length > 0 && _jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures }), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this item" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => _jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i)) })] })), descriptionHtml && _jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" }), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Listed by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && _jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" })] }) }))] }));
50
50
  }
51
51
  /**
52
52
  * Store reviews for the auction's seller.
@@ -204,5 +204,5 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
204
204
  slug: typeof r.slug === "string" ? r.slug : undefined,
205
205
  }));
206
206
  return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related, gridClassName: "grid grid-cols-2 gap-[var(--appkit-space-4)] sm:grid-cols-3 lg:grid-cols-4" })) })), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
207
- } }), renderAuctionStoreReviews(storeId), _jsx(AuctionBottomActions, { currentBid: currentBid, currency: currency, bidCount: bidCount, isEnded: isEnded, auctionEndDate: endDate })] })] }));
207
+ } }), renderAuctionStoreReviews(storeId), onPlaceBid && (_jsx(AuctionBottomActions, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow }))] })] }));
208
208
  }
@@ -3,7 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { normalizeError } from "../../../errors/normalize";
4
4
  import { useCallback, useEffect, useRef, useState } from "react";
5
5
  import { useRouter } from "next/navigation";
6
- import { Clock, Gavel, Heart, Play, ShoppingBag, Star } from "lucide-react";
6
+ import { Clock, Gavel, Play, ShoppingBag, Star } from "lucide-react";
7
+ import { WishlistHeartButton } from "../../wishlist/components/WishlistHeartButton";
7
8
  import { useLongPress } from "../../../react/hooks/useLongPress";
8
9
  import { MediaImage } from "../../media";
9
10
  import { useWishlistToggle } from "../../wishlist";
@@ -17,19 +18,17 @@ const CLS_STAR_ICON = "h-5 w-5 fill-warning text-warning";
17
18
  const CLS_LIVE_BADGE = "rounded-full bg-error-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-error-on-solid";
18
19
  const CLS_ENDING_BADGE = "rounded-full bg-warning-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-warning-on-solid";
19
20
  const CLS_RESERVE_BADGE = "inline-flex items-center gap-[var(--appkit-space-1)] rounded-full bg-warning-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-warning-on-solid";
20
- const CLS_HEART_ON = "fill-error text-error";
21
- const CLS_HEART_OFF = "text-zinc-400";
22
21
  function renderAuctionCardHero(props) {
23
22
  const { product, auctionHref, currentSrc, hovered, images, imageIndex, hasVideo, isEnded, isEndingSoon, isSelected, selectable, mergedLabels, onSelect, setHovered, handleSelect } = props;
24
23
  return (_jsxs(BaseListingCard.Hero, { aspect: "square", variant: "grid", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "absolute inset-0 block", children: _jsx(MediaImage, { src: currentSrc, alt: product.title, size: "card", fallback: "Auction image", className: `transition-transform duration-500 ${hovered && images.length > 1 ? "scale-105" : "scale-100"}` }) }), hasVideo && imageIndex === 0 ? (_jsx(Div, { className: "absolute right-2 top-2", children: _jsx(Span, { surface: "overlay-sm", layout: "flex-center", color: "inverse", className: "h-8 w-8", rounded: "full", children: _jsx(Play, { className: "h-4 w-4", "aria-label": mergedLabels.videoLabel }) }) })) : null, images.length > 1 ? (_jsx(Row, { gap: "xs", className: "pointer-events-none absolute bottom-2 left-1/2 -translate-x-1/2", children: images.map((_, index) => (_jsx(Span, { className: `transition-all duration-200 ${index === imageIndex ? "h-1.5 w-3 bg-[white]" : "h-1.5 w-1.5 bg-[white]/60"}`, rounded: "full" }, `${product.id}-image-${index}`))) })) : null, product.featured ? (_jsx(Div, { className: "pointer-events-none absolute left-2 top-2 z-10", children: _jsx(Star, { className: CLS_STAR_ICON }) })) : null, onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: handleSelect, label: isSelected ? mergedLabels.deselectItem : mergedLabels.selectItem, className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(Stack, { className: `absolute left-2 z-10 ${product.featured ? "top-8" : "top-2"}`, gap: "xs", children: [!isEnded ? _jsx(Span, { size: "xs", weight: "bold", className: CLS_LIVE_BADGE, children: mergedLabels.liveBadge }) : null, isEndingSoon ? _jsx(Span, { size: "xs", weight: "bold", className: CLS_ENDING_BADGE, children: mergedLabels.endingSoon }) : null, isEnded ? _jsx(Span, { color: "inverse", size: "xs", weight: "bold", surface: "overlay-lg", rounded: "full", padding: "pill-xs", children: mergedLabels.ended }) : null, product.status === "sold" ? _jsx(Span, { color: "inverse", size: "xs", weight: "bold", surface: "overlay-xl", rounded: "full", padding: "pill-xs", children: mergedLabels.sold }) : null] }), _jsx(Div, { className: "pointer-events-none absolute bottom-2 right-2 z-10", children: _jsxs(Span, { size: "xs", weight: "semibold", className: CLS_RESERVE_BADGE, children: [_jsx(Gavel, { className: "h-3 w-3" }), mergedLabels.typeBadge] }) })] }));
25
24
  }
26
25
  function renderAuctionCardInfoList(props) {
27
26
  const { product, auctionHref, displayBid, bidCount, isEnded, remaining, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
28
- return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(Button, { rounded: "full", type: "button", variant: "ghost", onClick: handleWishlist, disabled: wishlistLoading, "aria-label": inWishlist ? mergedLabels.removeFromWishlist : mergedLabels.addToWishlist, className: "shrink-0 p-[0.25rem]", children: _jsx(Heart, { className: `h-3.5 w-3.5 ${inWishlist ? CLS_HEART_ON : CLS_HEART_OFF}` }) })) : null] }), _jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Text, { className: "text-primary", size: "sm", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), _jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", className: `items-[center] py-[0.125rem] text-[11px] ${countdownClass}`, padding: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-2.5 w-2.5" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), bidCount > 0 && _jsx(Caption, { className: "text-[11px]", children: mergedLabels.totalBids(bidCount) })] }), !isEnded && (_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", className: "self-start px-[0.625rem] mt-0.5", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }))] }));
27
+ return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Text, { className: "text-primary", size: "sm", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), _jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", className: `items-[center] py-[0.125rem] text-[11px] ${countdownClass}`, padding: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-2.5 w-2.5" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), bidCount > 0 && _jsx(Caption, { className: "text-[11px]", children: mergedLabels.totalBids(bidCount) })] }), !isEnded && (_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", className: "self-start px-[0.625rem] mt-0.5", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }))] }));
29
28
  }
30
29
  function renderAuctionCardInfoGrid(props) {
31
30
  const { product, auctionHref, displayBid, bidCount, hasCurrentBid, isEnded, remaining, resolvedBuyoutPrice, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
32
- return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(Button, { rounded: "full", type: "button", variant: "ghost", onClick: handleWishlist, disabled: wishlistLoading, "aria-label": inWishlist ? mergedLabels.removeFromWishlist : mergedLabels.addToWishlist, className: "-mt-0.5 p-[0.25rem]", children: _jsx(Heart, { className: `h-4 w-4 ${inWishlist ? CLS_HEART_ON : CLS_HEART_OFF}` }) })) : null] }), _jsxs(Div, { children: [_jsx(Caption, { children: isEnded && hasCurrentBid ? mergedLabels.winningBid : hasCurrentBid ? mergedLabels.currentBid : mergedLabels.startingBid }), _jsx(Text, { className: "leading-none text-primary", size: "base", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), isEnded && product.winnerDisplayName && (_jsxs(Caption, { className: "mt-0.5", color: "muted", children: [mergedLabels.wonBy, ": ", maskDisplayName(product.winnerDisplayName)] }))] }), _jsxs(Row, { wrap: true, justify: "between", gap: "sm", className: "gap-x-2 gap-y-1", children: [_jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", textSize: "xs", className: `items-[center] ${countdownClass}`, paddingY: "y-2xs", paddingX: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-3 w-3" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), _jsx(Caption, { children: mergedLabels.totalBids(bidCount) })] }), _jsx(Row, { wrap: true, gap: "xs", className: "mt-auto", children: isEnded ? (_jsx(Button, { gap: "xs", type: "button", variant: "ghost", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1 cursor-not-allowed opacity-60", disabled: true, children: _jsx(Span, { children: mergedLabels.ended }) })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }), resolvedBuyoutPrice ? (
31
+ return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsxs(Div, { children: [_jsx(Caption, { children: isEnded && hasCurrentBid ? mergedLabels.winningBid : hasCurrentBid ? mergedLabels.currentBid : mergedLabels.startingBid }), _jsx(Text, { className: "leading-none text-primary", size: "base", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), isEnded && product.winnerDisplayName && (_jsxs(Caption, { className: "mt-0.5", color: "muted", children: [mergedLabels.wonBy, ": ", maskDisplayName(product.winnerDisplayName)] }))] }), _jsxs(Row, { wrap: true, justify: "between", gap: "sm", className: "gap-x-2 gap-y-1", children: [_jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", textSize: "xs", className: `items-[center] ${countdownClass}`, paddingY: "y-2xs", paddingX: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-3 w-3" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), _jsx(Caption, { children: mergedLabels.totalBids(bidCount) })] }), _jsx(Row, { wrap: true, gap: "xs", className: "mt-auto", children: isEnded ? (_jsx(Button, { gap: "xs", type: "button", variant: "ghost", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1 cursor-not-allowed opacity-60", disabled: true, children: _jsx(Span, { children: mergedLabels.ended }) })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }), resolvedBuyoutPrice ? (
33
32
  /* `secondary`, not `danger` — this button only navigates to the
34
33
  PDP, and `danger` is reserved for destructive actions (Rule #7
35
34
  kinds). ACTIONS.AUCTION["buy-it-now"] is itself `secondary`. */
@@ -5,6 +5,31 @@ export interface PlaceBidInput {
5
5
  bidAmount: number;
6
6
  autoMaxBid?: number;
7
7
  }
8
+ /**
9
+ * The `ActionResult` envelope every server action in this app returns.
10
+ *
11
+ * 🛑 Exactly ONE level. `onPlaceBid`/`onBuyNow` used to be typed
12
+ * `Promise<unknown>`, and the consumer's actions returned a *doubly*-wrapped
13
+ * `{ ok: true, data: { ok: false, error } }`. Reading the outer `ok` therefore
14
+ * saw `true` for every possible outcome — including every failure — so Buy Now
15
+ * placed a real bid and a real locked cart line and then silently
16
+ * `router.refresh()`ed a page that renders nothing from the cart. Typing these
17
+ * props is what makes a re-introduced second envelope a compile error rather
18
+ * than a silent no-op button.
19
+ */
20
+ export type BidActionEnvelope<T> = {
21
+ ok: true;
22
+ data: T;
23
+ } | {
24
+ ok: false;
25
+ error: string;
26
+ code?: string;
27
+ issues?: unknown[];
28
+ };
29
+ /** The subset of `BuyNowAuctionResult` this component actually consumes. */
30
+ export interface BuyNowSuccess {
31
+ checkoutUrl?: string;
32
+ }
8
33
  export interface PlaceBidFormClientProps {
9
34
  productId: string;
10
35
  currentBid: number;
@@ -17,8 +42,8 @@ export interface PlaceBidFormClientProps {
17
42
  buyNowPrice: number | null;
18
43
  bidCount: number;
19
44
  tags?: string[];
20
- onPlaceBid: (input: PlaceBidInput) => Promise<unknown>;
21
- onBuyNow?: () => Promise<unknown>;
45
+ onPlaceBid: (input: PlaceBidInput) => Promise<BidActionEnvelope<unknown>>;
46
+ onBuyNow?: () => Promise<BidActionEnvelope<BuyNowSuccess>>;
22
47
  }
23
48
  export declare function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid, tiers, minBidIncrementOverride, currency, isEnded, auctionEndDate, buyNowPrice, bidCount: ssrBidCount, tags, onPlaceBid, onBuyNow, }: PlaceBidFormClientProps): React.JSX.Element;
24
49
  /**
@@ -48,6 +48,12 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
48
48
  const [isBuyNowPending, startBuyNowTransition] = useTransition();
49
49
  const [success, setSuccess] = useState(false);
50
50
  const [showLoginModal, setShowLoginModal] = useState(false);
51
+ /**
52
+ * Buy Now has no field to hang an error off — it is a standalone button, not
53
+ * a form input — so it needs its own slot. Without one, every domain failure
54
+ * (`AUCTION_ENDED`, `BUY_NOW_UNAVAILABLE`, rate-limit) was silent.
55
+ */
56
+ const [buyNowError, setBuyNowError] = useState(null);
51
57
  /** Last amount the form itself wrote — see `seedAmount` below. */
52
58
  const lastSeeded = useRef(minBid);
53
59
  /**
@@ -140,43 +146,59 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
140
146
  }
141
147
  }
142
148
  function bidErrorMessage(result) {
143
- if (!result || typeof result !== "object" || !("ok" in result))
144
- return null;
145
- const r = result;
146
- if (r.ok !== false)
149
+ if (result.ok)
147
150
  return null;
148
- return (r.code && BID_ERROR_DISPLAY[r.code]) ?? r.error ?? "Failed to place bid. Please try again.";
151
+ return ((result.code && BID_ERROR_DISPLAY[result.code]) ??
152
+ result.error ??
153
+ "Failed to place bid. Please try again.");
154
+ }
155
+ /**
156
+ * Show a Buy Now failure.
157
+ *
158
+ * Every failure used to be indistinguishable from success and was discarded.
159
+ * An auth failure never THROWS across a server-action boundary — it arrives
160
+ * as a code — so `isAuthError` in the catch below could never fire for it.
161
+ *
162
+ * ONLY `UNAUTHENTICATED` (401) means "sign in". `FORBIDDEN` (403) is "signed
163
+ * in, but not allowed" — bidding on your own auction, or an active
164
+ * `place_bids` soft ban. A login prompt for those tells the buyer to fix
165
+ * something that is not the problem.
166
+ */
167
+ function reportBuyNowFailure(code, message) {
168
+ if (code === "UNAUTHENTICATED") {
169
+ setShowLoginModal(true);
170
+ return;
171
+ }
172
+ setBuyNowError((code && BID_ERROR_DISPLAY[code]) ??
173
+ message ??
174
+ "Buy Now failed. Please try again.");
149
175
  }
150
176
  function handleBuyNow() {
151
177
  if (!onBuyNow)
152
178
  return;
153
179
  setSuccess(false);
180
+ setBuyNowError(null);
154
181
  startBuyNowTransition(async () => {
155
182
  try {
156
183
  const result = await onBuyNow();
157
- if (result &&
158
- typeof result === "object" &&
159
- "ok" in result &&
160
- result.ok === false) {
161
- setSuccess(false);
184
+ if (!result.ok) {
185
+ reportBuyNowFailure(result.code, result.error);
162
186
  return;
163
187
  }
164
188
  setSuccess(true);
165
189
  // Buy-Now writes a locked cart line, so send the buyer straight to the
166
- // auction checkout lane to pay for it. This used to just call
167
- // `router.refresh()` and discard the action's result entirely, leaving
168
- // the buyer on the product page with no indication that anything was
169
- // owed — the action returned an orderId nobody ever used.
170
- const checkoutUrl = result?.checkoutUrl;
171
- if (checkoutUrl)
172
- router.push(checkoutUrl);
190
+ // auction checkout lane to pay for it. `buyNowAuction` deliberately
191
+ // never touches the product document, so falling through to
192
+ // `router.refresh()` re-renders a byte-identical page — which is
193
+ // exactly what "the button does nothing" looked like.
194
+ if (result.data.checkoutUrl)
195
+ router.push(result.data.checkoutUrl);
173
196
  else
174
197
  router.refresh();
175
198
  }
176
199
  catch (err) {
177
200
  void normalizeError(err);
178
- if (isAuthError(err))
179
- setShowLoginModal(true);
201
+ reportBuyNowFailure(isAuthError(err) ? "UNAUTHENTICATED" : undefined, err instanceof Error ? err.message : undefined);
180
202
  }
181
203
  });
182
204
  }
@@ -212,7 +234,7 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
212
234
  return applyZodIssues(parsed.error.issues, setFieldError);
213
235
  setSuccess(false);
214
236
  startTransition(() => submitBid(parsed.data.bidAmount, setFieldError));
215
- }, children: isEnded ? "Auction Ended" : "Place Bid" }), buyNowAvailable && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", size: "md", className: "w-full", type: "button", disabled: isBuyNowPending || isPending, isLoading: isBuyNowPending, onClick: handleBuyNow, children: `Buy Now — ${formatCurrency(buyNowPrice, currency)}` }), _jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Skips the bidding. The auction stays live until you pay \u2014 complete checkout within ", AUCTION_BUYOUT_WINDOW_MINUTES, " minutes."] })] }))] })) }), tags.length > 0 && (_jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", size: "xs", children: tag }, tag))) }) }))] }));
237
+ }, children: isEnded ? "Auction Ended" : "Place Bid" }), buyNowAvailable && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", size: "md", className: "w-full", type: "button", disabled: isBuyNowPending || isPending, isLoading: isBuyNowPending, onClick: handleBuyNow, children: `Buy Now — ${formatCurrency(buyNowPrice, currency)}` }), buyNowError && (_jsx(Text, { align: "center", size: "xs", variant: "error", role: "alert", children: buyNowError })), _jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Skips the bidding. The auction stays live until you pay \u2014 complete checkout within ", AUCTION_BUYOUT_WINDOW_MINUTES, " minutes."] })] }))] })) }), tags.length > 0 && (_jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", size: "xs", children: tag }, tag))) }) }))] }));
216
238
  }
217
239
  /**
218
240
  * `PlaceBidModalButton` — opens a modal that hosts the same PlaceBidFormClient.
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Button, Div, Row, Span } from "../../../ui";
3
3
  export function SocialAuthButtons({ onGoogle, disabled, dividerLabel, googleLabel, }) {
4
- return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: "relative", children: [_jsx(Row, { className: "absolute inset-0", align: "center", children: _jsx(Div, { className: "w-full border-t border-neutral-200" }) }), _jsx(Row, { textSize: "sm", className: "relative", justify: "center", children: _jsx(Span, { padding: "x-xs", surface: "default", color: "muted", children: dividerLabel }) })] }), _jsx(Div, { layout: "grid", gap: "3", className: "grid-cols-1", children: _jsxs(Button, { rounded: "md", type: "button", onClick: onGoogle, disabled: disabled, variant: "outline", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: "inline- justify-[center] border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text)] dark:text-[var(--appkit-color-text)] hover:bg-neutral-50 hover:bg-[var(--appkit-color-surface-elevated)] disabled:cursor-not-allowed disabled:opacity-50", children: [_jsxs("svg", { className: "mr-2 h-5 w-5", viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { fill: "currentColor", d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" }), _jsx("path", { fill: "currentColor", d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" }), _jsx("path", { fill: "currentColor", d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" }), _jsx("path", { fill: "currentColor", d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" })] }), googleLabel] }) })] }));
4
+ return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: "relative", children: [_jsx(Row, { className: "absolute inset-0", align: "center", children: _jsx(Div, { className: "w-full border-t border-neutral-200" }) }), _jsx(Row, { textSize: "sm", className: "relative", justify: "center", children: _jsx(Span, { padding: "x-xs", surface: "default", color: "muted", children: dividerLabel }) })] }), _jsx(Div, { layout: "grid", gap: "3", className: "grid-cols-1", children: _jsxs(Button, { rounded: "md", type: "button", onClick: onGoogle, disabled: disabled, variant: "outline", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: "inline- justify-[center] border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text)] dark:text-[var(--appkit-color-text)] hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50", children: [_jsxs("svg", { className: "mr-2 h-5 w-5", viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { fill: "currentColor", d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" }), _jsx("path", { fill: "currentColor", d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" }), _jsx("path", { fill: "currentColor", d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" }), _jsx("path", { fill: "currentColor", d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" })] }), googleLabel] }) })] }));
5
5
  }
@@ -74,10 +74,32 @@ export declare function useVerifyEmail(options?: {
74
74
  onSuccess?: (data: JsonValue) => void;
75
75
  onError?: (error: Error) => void;
76
76
  }): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, VerifyEmailData, unknown>;
77
+ /**
78
+ * Re-send the verification email — Firebase-native, like the send that
79
+ * happens at registration.
80
+ *
81
+ * ## What this used to do, and why it could not work
82
+ *
83
+ * It POSTed `/api/auth/resend-verification`, a route **deleted** in the
84
+ * Firebase-native auth migration. Root Cause #54 is the reason it can never
85
+ * come back: the Admin SDK cannot send email at all — it only *generates*
86
+ * action links — so the only way to deliver a verification mail without
87
+ * routing it through Resend is the CLIENT SDK's own
88
+ * `sendEmailVerification()`, which uses Firebase's hosted template.
89
+ *
90
+ * So every call 404'd, and `useRegister` above says in as many words that
91
+ * "the user can re-request verification later" — a documented recovery path
92
+ * that did not exist. Found by `audit-client-verb-match`'s NO_ROUTE rule.
93
+ *
94
+ * `ResendVerificationData.email` is accepted and unused: the client SDK
95
+ * re-sends for the CURRENTLY signed-in user and takes no address, so honouring
96
+ * an arbitrary email here would be a lie about what happens. Kept in the
97
+ * signature so the two existing call shapes stay source-compatible.
98
+ */
77
99
  export declare function useResendVerification(options?: {
78
100
  onSuccess?: (data: JsonValue) => void;
79
101
  onError?: (error: Error) => void;
80
- }): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, ResendVerificationData, unknown>;
102
+ }): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, void | ResendVerificationData, unknown>;
81
103
  export declare function useForgotPassword(options?: {
82
104
  onSuccess?: (data: JsonValue) => void;
83
105
  onError?: (error: Error) => void;
@@ -337,9 +337,35 @@ export function useVerifyEmail(options) {
337
337
  onError: options?.onError,
338
338
  });
339
339
  }
340
+ /**
341
+ * Re-send the verification email — Firebase-native, like the send that
342
+ * happens at registration.
343
+ *
344
+ * ## What this used to do, and why it could not work
345
+ *
346
+ * It POSTed `/api/auth/resend-verification`, a route **deleted** in the
347
+ * Firebase-native auth migration. Root Cause #54 is the reason it can never
348
+ * come back: the Admin SDK cannot send email at all — it only *generates*
349
+ * action links — so the only way to deliver a verification mail without
350
+ * routing it through Resend is the CLIENT SDK's own
351
+ * `sendEmailVerification()`, which uses Firebase's hosted template.
352
+ *
353
+ * So every call 404'd, and `useRegister` above says in as many words that
354
+ * "the user can re-request verification later" — a documented recovery path
355
+ * that did not exist. Found by `audit-client-verb-match`'s NO_ROUTE rule.
356
+ *
357
+ * `ResendVerificationData.email` is accepted and unused: the client SDK
358
+ * re-sends for the CURRENTLY signed-in user and takes no address, so honouring
359
+ * an arbitrary email here would be a lie about what happens. Kept in the
360
+ * signature so the two existing call shapes stay source-compatible.
361
+ */
340
362
  export function useResendVerification(options) {
341
363
  return useMutation({
342
- mutationFn: (data) => apiClient.post(AUTH_ENDPOINTS.RESEND_VERIFICATION, data),
364
+ mutationFn: async () => {
365
+ const authProvider = getClientAuthProvider();
366
+ await authProvider.sendEmailVerification();
367
+ return { success: true };
368
+ },
343
369
  onSuccess: options?.onSuccess,
344
370
  onError: options?.onError,
345
371
  });
@@ -11,7 +11,7 @@
11
11
  * Permission naming: admin:resource:action
12
12
  * Ban action naming: plain verb phrase — write_reviews, place_bids, etc.
13
13
  */
14
- export type Permission = "admin:dashboard:view" | "admin:users:read" | "admin:users:write" | "admin:users:delete" | "admin:user-bans:read" | "admin:user-bans:write" | "admin:products:read" | "admin:products:write" | "admin:products:delete" | "admin:orders:read" | "admin:orders:write" | "admin:returns:read" | "admin:returns:write" | "admin:stores:read" | "admin:stores:write" | "admin:store-addresses:read" | "admin:addresses:read" | "admin:addresses:write" | "admin:analytics:view" | "admin:payouts:read" | "admin:payouts:write" | "admin:audit-log:read" | "admin:categories:read" | "admin:categories:write" | "admin:categories:delete" | "admin:brands:read" | "admin:brands:write" | "admin:brands:delete" | "admin:coupons:read" | "admin:coupons:write" | "admin:coupons:delete" | "admin:deals:read" | "admin:deals:write" | "admin:featured:read" | "admin:featured:write" | "admin:reviews:read" | "admin:reviews:write" | "admin:reviews:delete" | "admin:blog:read" | "admin:blog:write" | "admin:blog:delete" | "admin:blog:publish" | "admin:bids:read" | "admin:bids:write" | "admin:offers:read" | "admin:offers:write" | "admin:media:read" | "admin:media:write" | "admin:media:delete" | "admin:site:read" | "admin:site:write" | "admin:settings:write" | "admin:navigation:read" | "admin:navigation:write" | "admin:sections:read" | "admin:sections:write" | "admin:carousel:read" | "admin:carousel:write" | "admin:carousel:delete" | "admin:ads:read" | "admin:ads:write" | "admin:ads:delete" | "admin:faqs:read" | "admin:faqs:write" | "admin:faqs:delete" | "admin:tester-checklist:read" | "admin:tester-checklist:write" | "admin:tester-checklist:delete" | "admin:tester-feedback:read" | "admin:tester-feedback:write" | "admin:newsletter:read" | "admin:newsletter:write" | "admin:contact:read" | "admin:events:read" | "admin:events:write" | "admin:events:delete" | "admin:event-entries:read" | "admin:event-entries:write" | "admin:support-tickets:read" | "admin:support-tickets:write" | "admin:shipments:read" | "admin:shipments:write" | "admin:catalogue:read" | "admin:catalogue:write" | "admin:support-tickets:assign" | "admin:support-tickets:close" | "admin:scammers:read" | "admin:scammers:write" | "admin:scammers:verify" | "admin:scammers:delete" | "admin:sessions:read" | "admin:sessions:delete" | "admin:notifications:read" | "admin:notifications:write" | "admin:carts:read" | "admin:wishlists:read" | "admin:feature-flags:read" | "admin:feature-flags:write" | "admin:copilot:view" | "admin:team:read" | "admin:team:write" | "admin:maintenance:view-server-errors" | "admin:maintenance:view-client-errors" | "admin:maintenance:view-function-errors" | "admin:maintenance:view-cloud-logs" | "admin:maintenance:view-payment-rollbacks" | "admin:maintenance:run-analysis" | "admin:maintenance:purge-errors";
14
+ export type Permission = "admin:dashboard:view" | "admin:users:read" | "admin:users:write" | "admin:users:delete" | "admin:user-bans:read" | "admin:user-bans:write" | "admin:products:read" | "admin:products:write" | "admin:products:delete" | "admin:orders:read" | "admin:orders:write" | "admin:returns:read" | "admin:returns:write" | "admin:stores:read" | "admin:stores:write" | "admin:store-addresses:read" | "admin:addresses:read" | "admin:addresses:write" | "admin:analytics:view" | "admin:payouts:read" | "admin:payouts:write" | "admin:audit-log:read" | "admin:categories:read" | "admin:categories:write" | "admin:categories:delete" | "admin:brands:read" | "admin:brands:write" | "admin:brands:delete" | "admin:coupons:read" | "admin:coupons:write" | "admin:coupons:delete" | "admin:deals:read" | "admin:deals:write" | "admin:featured:read" | "admin:featured:write" | "admin:reviews:read" | "admin:reviews:write" | "admin:reviews:delete" | "admin:blog:read" | "admin:blog:write" | "admin:blog:delete" | "admin:blog:publish" | "admin:bids:read" | "admin:bids:write" | "admin:offers:read" | "admin:offers:write" | "admin:media:read" | "admin:media:write" | "admin:media:delete" | "admin:site:read" | "admin:site:write" | "admin:settings:write" | "admin:navigation:read" | "admin:navigation:write" | "admin:sections:read" | "admin:sections:write" | "admin:carousel:read" | "admin:carousel:write" | "admin:carousel:delete" | "admin:ads:read" | "admin:ads:write" | "admin:ads:delete" | "admin:faqs:read" | "admin:faqs:write" | "admin:faqs:delete" | "admin:tester-checklist:read" | "admin:tester-checklist:write" | "admin:tester-checklist:delete" | "admin:tester-feedback:read" | "admin:tester-feedback:write" | "admin:newsletter:read" | "admin:newsletter:write" | "admin:contact:read" | "admin:events:read" | "admin:events:write" | "admin:events:delete" | "admin:event-entries:read" | "admin:event-entries:write" | "admin:support-tickets:read" | "admin:support-tickets:write" | "admin:shipments:read" | "admin:shipments:write" | "admin:catalogue:read" | "admin:catalogue:write" | "admin:grouped-listings:read" | "admin:grouped-listings:write" | "admin:support-tickets:assign" | "admin:support-tickets:close" | "admin:scammers:read" | "admin:scammers:write" | "admin:scammers:verify" | "admin:scammers:delete" | "admin:sessions:read" | "admin:sessions:delete" | "admin:notifications:read" | "admin:notifications:write" | "admin:carts:read" | "admin:wishlists:read" | "admin:feature-flags:read" | "admin:feature-flags:write" | "admin:copilot:view" | "admin:team:read" | "admin:team:write" | "admin:maintenance:view-server-errors" | "admin:maintenance:view-client-errors" | "admin:maintenance:view-function-errors" | "admin:maintenance:view-cloud-logs" | "admin:maintenance:view-payment-rollbacks" | "admin:maintenance:run-analysis" | "admin:maintenance:purge-errors";
15
15
  /**
16
16
  * Granular user actions that can be soft-banned individually.
17
17
  * A user may have multiple simultaneous soft bans of different types.
@@ -126,6 +126,8 @@ export const PERMISSION_GROUPS = {
126
126
  ],
127
127
  catalog_manager: [
128
128
  "admin:dashboard:view",
129
+ "admin:grouped-listings:read",
130
+ "admin:grouped-listings:write",
129
131
  "admin:categories:read",
130
132
  "admin:categories:write",
131
133
  "admin:categories:delete",
@@ -277,6 +279,7 @@ export const ROUTE_PERMISSION_MAP = {
277
279
  "/admin/audit-log": "admin:audit-log:read",
278
280
  "/admin/shipments": "admin:shipments:read",
279
281
  "/admin/catalogue-approvals": "admin:catalogue:read",
282
+ "/admin/grouped-listings": "admin:grouped-listings:read",
280
283
  "/admin/categories": "admin:categories:read",
281
284
  "/admin/brands": "admin:brands:read",
282
285
  "/admin/coupons": "admin:coupons:read",
@@ -1,9 +1,24 @@
1
- import type { DocumentReference } from "firebase-admin/firestore";
1
+ import type { DocumentReference, DocumentSnapshot } from "firebase-admin/firestore";
2
2
  import { BaseRepository, type FirebaseSieveFields, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
3
3
  import { type SessionCreateInput, type SessionDocument, type SessionUpdateInput } from "../schemas";
4
4
  export declare class SessionRepository extends BaseRepository<SessionDocument> {
5
5
  static readonly SIEVE_FIELDS: FirebaseSieveFields;
6
+ /**
7
+ * Session PII, encrypted by BaseRepository's write hooks.
8
+ *
9
+ * This repository had no encryption at all — `createSession` calls
10
+ * `createWithId`, and before the hooks existed that method applied nothing —
11
+ * so device IPs, user agents and city were stored in cleartext.
12
+ *
13
+ * `deviceInfo.browser` / `deviceInfo.os` are deliberately NOT here: they are
14
+ * low-entropy strings the admin sessions view filters on (see SIEVE_FIELDS
15
+ * above), and encrypting them would break those queries while revealing
16
+ * nothing an IP does not.
17
+ */
18
+ protected piiFields: readonly ["deviceInfo.ip", "deviceInfo.userAgent", "location.city"];
6
19
  constructor();
20
+ /** Decrypt on read so callers see the same shape they wrote. */
21
+ protected mapDoc<D = SessionDocument>(snap: DocumentSnapshot): D;
7
22
  list(model: SieveModel): Promise<FirebaseSieveResult<SessionDocument>>;
8
23
  listForUser(userId: string, model: SieveModel): Promise<FirebaseSieveResult<SessionDocument>>;
9
24
  createSession(userId: string, data: Omit<SessionCreateInput, "userId" | "expiresAt" | "isActive">): Promise<SessionDocument>;
@@ -3,10 +3,33 @@ import { DatabaseError } from "../../../errors";
3
3
  import { serverLogger } from "../../../monitoring";
4
4
  import { BaseRepository, parseSieveDateValue, } from "../../../providers/db-firebase";
5
5
  import { generateSessionId } from "../token-helpers";
6
+ import { decryptPiiFields } from "../../../security/pii-encrypt";
6
7
  import { SESSION_COLLECTION, SESSION_EXPIRATION_MS, SESSION_FIELDS, } from "../schemas";
7
8
  export class SessionRepository extends BaseRepository {
8
9
  constructor() {
9
10
  super(SESSION_COLLECTION);
11
+ /**
12
+ * Session PII, encrypted by BaseRepository's write hooks.
13
+ *
14
+ * This repository had no encryption at all — `createSession` calls
15
+ * `createWithId`, and before the hooks existed that method applied nothing —
16
+ * so device IPs, user agents and city were stored in cleartext.
17
+ *
18
+ * `deviceInfo.browser` / `deviceInfo.os` are deliberately NOT here: they are
19
+ * low-entropy strings the admin sessions view filters on (see SIEVE_FIELDS
20
+ * above), and encrypting them would break those queries while revealing
21
+ * nothing an IP does not.
22
+ */
23
+ this.piiFields = [
24
+ "deviceInfo.ip",
25
+ "deviceInfo.userAgent",
26
+ "location.city",
27
+ ];
28
+ }
29
+ /** Decrypt on read so callers see the same shape they wrote. */
30
+ mapDoc(snap) {
31
+ const raw = super.mapDoc(snap);
32
+ return decryptPiiFields(raw, [...this.piiFields]);
10
33
  }
11
34
  async list(model) {
12
35
  return this.sieveQuery(model, SessionRepository.SIEVE_FIELDS);
@@ -1,7 +1,7 @@
1
1
  import { normalizeError } from "../../../errors/normalize";
2
2
  import { DatabaseError } from "../../../errors";
3
3
  import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
4
- import { USER_PII_FIELDS, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptPiiFields, encryptShippingConfig, piiBlindIndex, } from "../../../security";
4
+ import { USER_PII_FIELDS, USER_PII_INDEX_MAP, piiIndicesFor, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptPiiFields, encryptShippingConfig, piiBlindIndex, } from "../../../security";
5
5
  import { USER_COLLECTION, USER_FIELDS, createUserId, } from "../schemas";
6
6
  export class UserRepository extends BaseRepository {
7
7
  constructor() {
@@ -23,7 +23,18 @@ export class UserRepository extends BaseRepository {
23
23
  // addPiiIndices is intentionally NOT called here — it spreads the original
24
24
  // plaintext `data` back into the result, which would overwrite the encrypted
25
25
  // ciphertext with the original plaintext values, defeating the encryption.
26
- const encrypted = encryptPiiFields(data, [...USER_PII_FIELDS]);
26
+ //
27
+ // The mapped indices below are not optional. `encryptPiiFields` derives its
28
+ // sibling name mechanically as `${field}Index`, so `phoneNumber` produces
29
+ // `phoneNumberIndex` — but `findByPhone` queries `USER_FIELDS.PHONE_INDEX`,
30
+ // which is `phoneIndex`. Only the seed paths ever wrote that name, so
31
+ // **phone lookup could never match a user created by the app.**
32
+ // `piiIndicesFor` applies USER_PII_INDEX_MAP's real names and, unlike
33
+ // `addPiiIndices`, carries no plaintext back over the ciphertext.
34
+ const encrypted = {
35
+ ...encryptPiiFields(data, [...USER_PII_FIELDS]),
36
+ ...piiIndicesFor(data, USER_PII_INDEX_MAP),
37
+ };
27
38
  const access = encrypted;
28
39
  if (access.payoutDetails) {
29
40
  access.payoutDetails = encryptPayoutDetails(access.payoutDetails);